diff --git a/README.md b/README.md index a7586d0..cc25e38 100644 --- a/README.md +++ b/README.md @@ -24,37 +24,37 @@ - **Install pasarguard with SQLite**: ```bash - curl -fsSLo /tmp/pg.sh https://github.com/PasarGuard/scripts/raw/main/pasarguard.sh && sudo bash /tmp/pg.sh install + sudo bash -c "$(curl -fsSL https://github.com/PasarGuard/scripts/raw/main/pasarguard.sh)" @ install ``` - **Install pasarguard with MySQL**: ```bash - curl -fsSLo /tmp/pg.sh https://github.com/PasarGuard/scripts/raw/main/pasarguard.sh && sudo bash /tmp/pg.sh install --database mysql + sudo bash -c "$(curl -fsSL https://github.com/PasarGuard/scripts/raw/main/pasarguard.sh)" @ install --database mysql ``` - **Install pasarguard with PostgreSQL**: ```bash - curl -fsSLo /tmp/pg.sh https://github.com/PasarGuard/scripts/raw/main/pasarguard.sh && sudo bash /tmp/pg.sh install --database postgresql + sudo bash -c "$(curl -fsSL https://github.com/PasarGuard/scripts/raw/main/pasarguard.sh)" @ install --database postgresql ``` - **Install pasarguard with TimescaleDB(v1+ only) and pre-release version**: ```bash - curl -fsSLo /tmp/pg.sh https://github.com/PasarGuard/scripts/raw/main/pasarguard.sh && sudo bash /tmp/pg.sh install --database timescaledb --pre-release + sudo bash -c "$(curl -fsSL https://github.com/PasarGuard/scripts/raw/main/pasarguard.sh)" @ install --database timescaledb --pre-release ``` - **Install pasarguard with MariaDB and Dev branch**: ```bash - curl -fsSLo /tmp/pg.sh https://github.com/PasarGuard/scripts/raw/main/pasarguard.sh && sudo bash /tmp/pg.sh install --database mariadb --dev + sudo bash -c "$(curl -fsSL https://github.com/PasarGuard/scripts/raw/main/pasarguard.sh)" @ install --database mariadb --dev ``` - **Install pasarguard with MariaDB and Manual version**: ```bash - curl -fsSLo /tmp/pg.sh https://github.com/PasarGuard/scripts/raw/main/pasarguard.sh && sudo bash /tmp/pg.sh install --database mariadb --version v0.5.2 + sudo bash -c "$(curl -fsSL https://github.com/PasarGuard/scripts/raw/main/pasarguard.sh)" @ install --database mariadb --version v0.5.2 ``` ## Installing Node @@ -63,22 +63,22 @@ - **Install Node** ```bash - curl -fsSLo /tmp/pg-node.sh https://github.com/PasarGuard/scripts/raw/main/pg-node.sh && sudo bash /tmp/pg-node.sh install + sudo bash -c "$(curl -fsSL https://github.com/PasarGuard/scripts/raw/main/pg-node.sh)" @ install ``` - **Install Node Manual version:** ```bash - curl -fsSLo /tmp/pg-node.sh https://github.com/PasarGuard/scripts/raw/main/pg-node.sh && sudo bash /tmp/pg-node.sh install --version 0.1.0 + sudo bash -c "$(curl -fsSL https://github.com/PasarGuard/scripts/raw/main/pg-node.sh)" @ install --version 0.1.0 ``` - **Install Node pre-release version:** ```bash - curl -fsSLo /tmp/pg-node.sh https://github.com/PasarGuard/scripts/raw/main/pg-node.sh && sudo bash /tmp/pg-node.sh install --pre-release + sudo bash -c "$(curl -fsSL https://github.com/PasarGuard/scripts/raw/main/pg-node.sh)" @ install --pre-release ``` - **Install Node with custom name:** ```bash - curl -fsSLo /tmp/pg-node.sh https://github.com/PasarGuard/scripts/raw/main/pg-node.sh && sudo bash /tmp/pg-node.sh install --name Node2 + sudo bash -c "$(curl -fsSL https://github.com/PasarGuard/scripts/raw/main/pg-node.sh)" @ install --name Node2 ``` > šŸ“Œ **Tip:** diff --git a/node.yml b/docker-compose/node.yml similarity index 100% rename from node.yml rename to docker-compose/node.yml diff --git a/pasarguard-mariadb.yml b/docker-compose/pasarguard-mariadb.yml similarity index 100% rename from pasarguard-mariadb.yml rename to docker-compose/pasarguard-mariadb.yml diff --git a/pasarguard-mysql.yml b/docker-compose/pasarguard-mysql.yml similarity index 100% rename from pasarguard-mysql.yml rename to docker-compose/pasarguard-mysql.yml diff --git a/pasarguard-postgresql.yml b/docker-compose/pasarguard-postgresql.yml similarity index 100% rename from pasarguard-postgresql.yml rename to docker-compose/pasarguard-postgresql.yml diff --git a/pasarguard-timescaledb.yml b/docker-compose/pasarguard-timescaledb.yml similarity index 100% rename from pasarguard-timescaledb.yml rename to docker-compose/pasarguard-timescaledb.yml diff --git a/lib/common.sh b/lib/common.sh new file mode 100644 index 0000000..6f6d1b7 --- /dev/null +++ b/lib/common.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash + +colorized_echo() { + local color="$1" + local text="$2" + local style="${3:-0}" + + case "$color" in + red) + printf "\e[${style};91m%s\e[0m\n" "$text" + ;; + green) + printf "\e[${style};92m%s\e[0m\n" "$text" + ;; + yellow) + printf "\e[${style};93m%s\e[0m\n" "$text" + ;; + blue) + printf "\e[${style};94m%s\e[0m\n" "$text" + ;; + magenta) + printf "\e[${style};95m%s\e[0m\n" "$text" + ;; + cyan) + printf "\e[${style};96m%s\e[0m\n" "$text" + ;; + *) + printf "%s\n" "$text" + ;; + esac +} + +die() { + colorized_echo red "$*" + exit 1 +} + +temp_root_dir() { + local root="" + + if [ -n "${APP_TMP_DIR:-}" ]; then + root="$APP_TMP_DIR" + elif [ -n "${DATA_DIR:-}" ]; then + root="$DATA_DIR/tmp" + elif [ -n "${APP_NAME:-}" ]; then + root="/var/lib/$APP_NAME/tmp" + else + root="/var/lib/pasarguard-scripts/tmp" + fi + + mkdir -p "$root" + printf '%s\n' "$root" +} + +create_temp_dir() { + local prefix="${1:-tmpdir}" + local root="" + local candidate="" + local attempt=0 + + root=$(temp_root_dir) + while [ "$attempt" -lt 20 ]; do + candidate="${root}/${prefix}-$$-${RANDOM}-${attempt}" + if mkdir "$candidate" 2>/dev/null; then + printf '%s\n' "$candidate" + return 0 + fi + attempt=$((attempt + 1)) + done + + die "Failed to create temporary directory in $root" +} + +create_temp_file() { + local prefix="${1:-tmpfile}" + local suffix="${2:-}" + local root="" + + root=$(temp_root_dir) + create_temp_file_in_dir "$root" "$prefix" "$suffix" +} + +create_temp_file_in_dir() { + local dir="$1" + local prefix="${2:-tmpfile}" + local suffix="${3:-}" + local candidate="" + local attempt=0 + + mkdir -p "$dir" + while [ "$attempt" -lt 20 ]; do + candidate="${dir}/${prefix}-$$-${RANDOM}-${attempt}${suffix}" + if (set -C; : >"$candidate") 2>/dev/null; then + printf '%s\n' "$candidate" + return 0 + fi + attempt=$((attempt + 1)) + done + + die "Failed to create temporary file in $dir" +} diff --git a/lib/docker.sh b/lib/docker.sh new file mode 100644 index 0000000..e43cde2 --- /dev/null +++ b/lib/docker.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash + +install_docker() { + colorized_echo blue "Installing Docker" + if ! bash -o pipefail -c 'curl -fsSL https://get.docker.com | sh'; then + die "Failed to install Docker" + fi + colorized_echo green "Docker installed successfully" +} + +detect_compose() { + if docker compose version >/dev/null 2>&1; then + COMPOSE='docker compose' + elif docker-compose version >/dev/null 2>&1; then + COMPOSE='docker-compose' + else + die "docker compose not found" + fi +} + +compose_up() { + $COMPOSE -f "$COMPOSE_FILE" -p "$APP_NAME" up -d --remove-orphans +} + +compose_down() { + $COMPOSE -f "$COMPOSE_FILE" -p "$APP_NAME" down +} + +compose_logs() { + $COMPOSE -f "$COMPOSE_FILE" -p "$APP_NAME" logs +} + +compose_logs_follow() { + $COMPOSE -f "$COMPOSE_FILE" -p "$APP_NAME" logs -f +} diff --git a/lib/env.sh b/lib/env.sh new file mode 100644 index 0000000..d49a6e2 --- /dev/null +++ b/lib/env.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash + +replace_or_append_env_var() { + local key="$1" + local value="$2" + local quote_value="${3:-false}" + local target_file="${4:-$ENV_FILE}" + local formatted_value="$value" + local escaped_value="" + + if [ "$quote_value" = "true" ]; then + local sanitized_value="${value//\"/\\\"}" + formatted_value="\"$sanitized_value\"" + fi + + escaped_value=$(printf '%s' "$formatted_value" | sed -e 's/[&|\\]/\\&/g') + + if grep -q "^$key=" "$target_file"; then + sed -i "s|^$key=.*|$key=$escaped_value|" "$target_file" + else + printf '%s=%s\n' "$key" "$formatted_value" >>"$target_file" + fi +} + +set_or_uncomment_env_var() { + local key="$1" + local value="$2" + local quote_value="${3:-false}" + local target_file="${4:-$ENV_FILE}" + local formatted_value="$value" + local tmp_file="" + local target_dir="" + + if [ "$quote_value" = "true" ]; then + local sanitized_value="${value//\"/\\\"}" + formatted_value="\"$sanitized_value\"" + fi + + [ -f "$target_file" ] || touch "$target_file" + target_dir=$(dirname "$target_file") + tmp_file=$(create_temp_file_in_dir "$target_dir" "env-edit" ".tmp") + + awk -v env_key="$key" -v env_line="${key} = ${formatted_value}" ' + BEGIN { replaced = 0 } + { + if ($0 ~ "^[[:space:]]*#?[[:space:]]*" env_key "[[:space:]]*=") { + if (replaced == 0) { + print env_line + replaced = 1 + } + next + } + print + } + END { + if (replaced == 0) { + print env_line + } + } + ' "$target_file" >"$tmp_file" + + mv "$tmp_file" "$target_file" +} + +comment_out_env_var() { + local key="$1" + local target_file="${2:-$ENV_FILE}" + local tmp_file="" + local target_dir="" + + [ -f "$target_file" ] || return 0 + target_dir=$(dirname "$target_file") + tmp_file=$(create_temp_file_in_dir "$target_dir" "env-comment" ".tmp") + + awk -v env_key="$key" ' + BEGIN { done = 0 } + { + if ($0 ~ "^[[:space:]]*#?[[:space:]]*" env_key "[[:space:]]*=") { + if (done == 0) { + line = $0 + sub("^[[:space:]]*#?[[:space:]]*" env_key "[[:space:]]*=[[:space:]]*", "", line) + print "# " env_key " = " line + done = 1 + } + next + } + print + } + ' "$target_file" >"$tmp_file" + + mv "$tmp_file" "$target_file" +} + +delete_env_var() { + local key="$1" + local target_file="${2:-$ENV_FILE}" + + [ -f "$target_file" ] || return 0 + sed -i "/^[[:space:]]*${key}[[:space:]]*=/d" "$target_file" +} diff --git a/lib/github.sh b/lib/github.sh new file mode 100644 index 0000000..17c1e16 --- /dev/null +++ b/lib/github.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash + +SHARED_LIB_INSTALL_DIR="/usr/local/lib/pasarguard-scripts/lib" + +github_raw_url() { + local repo="$1" + local path="$2" + + printf 'https://github.com/%s/raw/main/%s\n' "$repo" "$path" +} + +github_download_file() { + local url="$1" + local target_path="$2" + + curl -fsSL "$url" -o "$target_path" +} + +github_install_script_from_repo() { + local repo="$1" + local script_name="$2" + local install_name="$3" + local tmp_file="" + + tmp_file=$(mktemp) || return 1 + trap 'rm -f "$tmp_file"' RETURN + + if ! curl -fSL "$(github_raw_url "$repo" "$script_name")" -o "$tmp_file"; then + trap - RETURN + rm -f "$tmp_file" + return 1 + fi + + if ! chmod 755 "$tmp_file"; then + trap - RETURN + rm -f "$tmp_file" + return 1 + fi + + if ! install -m 755 "$tmp_file" "/usr/local/bin/$install_name"; then + trap - RETURN + rm -f "$tmp_file" + return 1 + fi + + trap - RETURN + rm -f "$tmp_file" +} + +install_shared_libs_from_local() { + local source_dir="$1" + shift + local lib_name="" + + mkdir -p "$SHARED_LIB_INSTALL_DIR" + for lib_name in "$@"; do + if [ -f "$source_dir/lib/$lib_name" ]; then + install -m 644 "$source_dir/lib/$lib_name" "$SHARED_LIB_INSTALL_DIR/$lib_name" + fi + done +} + +install_shared_libs_from_repo() { + local fetch_repo="$1" + shift + local tmp_dir="" + local lib_name="" + + tmp_dir=$(create_temp_dir "shared-libs") + mkdir -p "$SHARED_LIB_INSTALL_DIR" + + for lib_name in "$@"; do + github_download_file "$(github_raw_url "$fetch_repo" "lib/$lib_name")" "$tmp_dir/$lib_name" + install -m 644 "$tmp_dir/$lib_name" "$SHARED_LIB_INSTALL_DIR/$lib_name" + done + + rm -rf "$tmp_dir" +} diff --git a/lib/pasarguard-backup.sh b/lib/pasarguard-backup.sh new file mode 100644 index 0000000..b3471c6 --- /dev/null +++ b/lib/pasarguard-backup.sh @@ -0,0 +1,1402 @@ +#!/usr/bin/env bash + +mask_telegram_bot_key() { + local secret="$1" + local length=${#secret} + + if [ -z "$secret" ]; then + printf '%s\n' "" + return 0 + fi + + if [ "$length" -le 6 ]; then + printf '****%s\n' "$secret" + return 0 + fi + + printf '****%s\n' "${secret: -6}" +} + +filter_backup_cron_entries() { + local source_file="$1" + local target_file="$2" + + grep -F -v "# pasarguard-backup-service" "$source_file" >"$target_file" || true +} + +send_backup_to_telegram() { + if [ -f "$ENV_FILE" ]; then + while IFS='=' read -r key value; do + if [[ -z "$key" || "$key" =~ ^# ]]; then + continue + fi + key=$(echo "$key" | xargs) + value=$(echo "$value" | xargs) + value=$(echo "$value" | sed -E 's/^["'"'"'](.*)["'"'"']$/\1/') + if [[ "$key" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]]; then + export "$key"="$value" + else + colorized_echo yellow "Skipping invalid line in .env: $key=$value" + fi + done <"$ENV_FILE" + else + colorized_echo red "Environment file (.env) not found." + exit 1 + fi + + if [ "$BACKUP_SERVICE_ENABLED" != "true" ]; then + colorized_echo yellow "Backup service is not enabled. Skipping Telegram upload." + return + fi + + # Validate Telegram configuration + if [ -z "$BACKUP_TELEGRAM_BOT_KEY" ]; then + colorized_echo red "Error: BACKUP_TELEGRAM_BOT_KEY is not set in .env file" + return 1 + fi + + if [ -z "$BACKUP_TELEGRAM_CHAT_ID" ]; then + colorized_echo red "Error: BACKUP_TELEGRAM_CHAT_ID is not set in .env file" + return 1 + fi + + local proxy_url="" + local curl_proxy_args=() + if proxy_url=$(get_backup_proxy_url); then + curl_proxy_args=(--proxy "$proxy_url") + fi + + local server_ip="$(curl "${curl_proxy_args[@]}" -4 -s --max-time 5 ifconfig.me 2>/dev/null | grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$')" + if [ -z "$server_ip" ]; then + server_ip=$(hostname -I 2>/dev/null | awk '{print $1}') + fi + if [ -z "$server_ip" ]; then + server_ip="Unknown IP" + fi + local backup_dir="$APP_DIR/backup" + local latest_backup=$(ls -t "$backup_dir" 2>/dev/null | head -n 1) + + if [ -z "$latest_backup" ]; then + colorized_echo red "No backups found to send." + return 1 + fi + + local backup_paths=() + local uploaded_files=() + local cleanup_dir="" + + local telegram_split_bytes=$((49 * 1000 * 1000)) + + if [[ "$latest_backup" =~ \.part[0-9]{2}\.zip$ ]]; then + local base="${latest_backup%%.part*}" + while IFS= read -r file; do + [ -n "$file" ] && backup_paths+=("$file") + done < <(find "$backup_dir" -maxdepth 1 -type f -name "${base}.part*.zip" | sort) + if [ ${#backup_paths[@]} -eq 0 ]; then + colorized_echo red "Incomplete backup parts for $base" + return 1 + fi + elif [[ "$latest_backup" =~ \.z[0-9]{2}$ ]]; then + local base="${latest_backup%.z??}" + while IFS= read -r file; do + [ -n "$file" ] && backup_paths+=("$file") + done < <(find "$backup_dir" -maxdepth 1 -type f -name "${base}.z[0-9][0-9]" | sort) + if [ -f "$backup_dir/${base}.zip" ]; then + backup_paths+=("$backup_dir/${base}.zip") + else + colorized_echo red "Missing final .zip file for split archive $base" + return 1 + fi + elif [[ "$latest_backup" =~ \.zip$ ]]; then + local base="${latest_backup%.zip}" + local split_files=() + while IFS= read -r file; do + [ -n "$file" ] && split_files+=("$file") + done < <(find "$backup_dir" -maxdepth 1 -type f -name "${base}.z[0-9][0-9]" | sort) + if [ ${#split_files[@]} -gt 0 ]; then + backup_paths=("${split_files[@]}") + fi + backup_paths+=("$backup_dir/$latest_backup") + elif [[ "$latest_backup" =~ \.tar\.gz$ ]]; then + cleanup_dir="/tmp/pasarguard_backup_split" + rm -rf "$cleanup_dir" + mkdir -p "$cleanup_dir" + local legacy_backup="$backup_dir/$latest_backup" + local backup_size=$(du -m "$legacy_backup" | cut -f1) + if [ "$backup_size" -gt 49 ]; then + colorized_echo yellow "Legacy backup is larger than 49MB. Splitting before upload..." + split -b "$telegram_split_bytes" "$legacy_backup" "$cleanup_dir/${latest_backup}_part_" + else + cp "$legacy_backup" "$cleanup_dir/$latest_backup" + fi + while IFS= read -r file; do + [ -n "$file" ] && backup_paths+=("$file") + done < <(find "$cleanup_dir" -maxdepth 1 -type f -print | sort) + if [ ${#backup_paths[@]} -eq 0 ]; then + colorized_echo red "Failed to prepare legacy backup for upload." + rm -rf "$cleanup_dir" + return 1 + fi + else + colorized_echo red "Unsupported backup format: $latest_backup" + return 1 + fi + + local backup_time=$(date "+%Y-%m-%d %H:%M:%S %Z") + + for part in "${backup_paths[@]}"; do + local part_name=$(basename "$part") + local custom_filename="$part_name" + + local escaped_server_ip=$(printf '%s' "$server_ip" | sed 's/[_*\[\]()~`>#+\-=|{}!.]/\\&/g') + local escaped_filename=$(printf '%s' "$custom_filename" | sed 's/[_*\[\]()~`>#+\-=|{}!.]/\\&/g') + local escaped_time=$(printf '%s' "$backup_time" | sed 's/[_*\[\]()~`>#+\-=|{}!.]/\\&/g') + local caption="šŸ“¦ *Backup Information*\n🌐 *Server IP*: \`$escaped_server_ip\`\nšŸ“ *Backup File*: \`$escaped_filename\`\nā° *Backup Time*: \`$escaped_time\`" + + local response=$(curl "${curl_proxy_args[@]}" -s -w "\n%{http_code}" -F chat_id="$BACKUP_TELEGRAM_CHAT_ID" \ + -F document=@"$part;filename=$custom_filename" \ + -F caption="$(printf '%b' "$caption")" \ + -F parse_mode="MarkdownV2" \ + "https://api.telegram.org/bot$BACKUP_TELEGRAM_BOT_KEY/sendDocument" 2>&1) + + local http_code=$(echo "$response" | tail -n1) + local response_body=$(echo "$response" | sed '$d') + + if [ "$http_code" == "200" ]; then + # Check if response contains "ok":true + if echo "$response_body" | grep -q '"ok":true'; then + uploaded_files+=("$custom_filename") + colorized_echo green "Backup part $custom_filename successfully sent to Telegram." + else + # Extract error message from Telegram response + local error_msg=$(echo "$response_body" | grep -o '"description":"[^"]*"' | cut -d'"' -f4 || echo "Unknown error") + colorized_echo red "Failed to send backup part $custom_filename to Telegram: $error_msg" + echo "Telegram API status: $http_code" >&2 + echo "Telegram API Response: $response_body" >&2 + fi + else + local error_msg=$(echo "$response_body" | grep -o '"description":"[^"]*"' | cut -d'"' -f4 || echo "HTTP $http_code") + colorized_echo red "Failed to send backup part $custom_filename to Telegram: $error_msg" + echo "Telegram API Response: $response_body" >&2 + fi + done + + if [ ${#uploaded_files[@]} -gt 0 ]; then + local files_list="" + for file in "${uploaded_files[@]}"; do + files_list+="- $file"$'\n' + done + files_list="${files_list%$'\n'}" + + local info_message=$'šŸ“¦ Backup Upload Summary\n' + info_message+=$'──────────────────────\n' + info_message+="🌐 Server IP: $server_ip"$'\n' + info_message+="ā° Time: $backup_time"$'\n' + info_message+=$'\nāœ… Files Uploaded:\n' + info_message+="$files_list"$'\n' + info_message+=$'\nšŸ“‚ Extraction Guide:\n' + info_message+=$'🪟 Windows: Install and use 7-Zip. Place the .zip and every .zXX part together, then start extraction from the .zip file.\n' + info_message+=$'🐧 Linux: Run unzip (e.g., unzip backup_xxx.zip) with all .zXX parts in the same directory.\n' + info_message+=$'šŸŽ macOS: Use Archive Utility or run unzip backup_xxx.zip from Terminal with the .zXX parts beside the .zip file.\n' + info_message+=$'āš ļø Always download the .zip and every .zXX part before extracting.' + + curl "${curl_proxy_args[@]}" -s -X POST "https://api.telegram.org/bot$BACKUP_TELEGRAM_BOT_KEY/sendMessage" \ + -d chat_id="$BACKUP_TELEGRAM_CHAT_ID" \ + -d text="$info_message" >/dev/null 2>&1 || true + fi + + if [ -n "$cleanup_dir" ]; then + rm -rf "$cleanup_dir" + fi +} + +send_backup_error_to_telegram() { + local error_messages=$1 + local log_file=$2 + local proxy_url="" + local curl_proxy_args=() + if proxy_url=$(get_backup_proxy_url); then + curl_proxy_args=(--proxy "$proxy_url") + fi + local server_ip="$(curl "${curl_proxy_args[@]}" -4 -s --max-time 5 ifconfig.me 2>/dev/null | grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$')" + if [ -z "$server_ip" ]; then + server_ip=$(hostname -I 2>/dev/null | awk '{print $1}') + fi + if [ -z "$server_ip" ]; then + server_ip="Unknown IP" + fi + local error_time=$(date "+%Y-%m-%d %H:%M:%S %Z") + local message="āš ļø Backup Error Notification +🌐 Server IP: $server_ip +āŒ Errors: $error_messages +ā° Time: $error_time" + + local max_length=1000 + if [ ${#message} -gt $max_length ]; then + message="${message:0:$((max_length - 25))}... +[Message truncated]" + fi + + curl "${curl_proxy_args[@]}" -s -X POST "https://api.telegram.org/bot$BACKUP_TELEGRAM_BOT_KEY/sendMessage" \ + -d chat_id="$BACKUP_TELEGRAM_CHAT_ID" \ + -d text="$message" >/dev/null 2>&1 && + colorized_echo green "Backup error notification sent to Telegram." || + colorized_echo red "Failed to send error notification to Telegram." + + if [ -f "$log_file" ]; then + + response=$(curl "${curl_proxy_args[@]}" -s -w "%{http_code}" -o /tmp/tg_response.json \ + -F chat_id="$BACKUP_TELEGRAM_CHAT_ID" \ + -F document=@"$log_file;filename=backup_error.log" \ + -F caption="šŸ“œ Backup Error Log - $error_time" \ + "https://api.telegram.org/bot$BACKUP_TELEGRAM_BOT_KEY/sendDocument") + + http_code="${response:(-3)}" + if [ "$http_code" -eq 200 ]; then + colorized_echo green "Backup error log sent to Telegram." + else + colorized_echo red "Failed to send backup error log to Telegram. HTTP code: $http_code" + cat /tmp/tg_response.json + fi + else + colorized_echo red "Log file not found: $log_file" + fi +} + +backup_service() { + local telegram_bot_key="" + local telegram_chat_id="" + local cron_schedule="" + local interval_hours="" + local backup_proxy_enabled="false" + local backup_proxy_url="" + + colorized_echo blue "=====================================" + colorized_echo blue " Welcome to Backup Service " + colorized_echo blue "=====================================" + + if grep -q "BACKUP_SERVICE_ENABLED=true" "$ENV_FILE"; then + while true; do + telegram_bot_key=$(awk -F'=' '/^BACKUP_TELEGRAM_BOT_KEY=/ {print $2}' "$ENV_FILE") + telegram_chat_id=$(awk -F'=' '/^BACKUP_TELEGRAM_CHAT_ID=/ {print $2}' "$ENV_FILE") + cron_schedule=$(awk -F'=' '/^BACKUP_CRON_SCHEDULE=/ {print $2}' "$ENV_FILE" | tr -d '"') + backup_proxy_enabled=$(awk -F'=' '/^BACKUP_PROXY_ENABLED=/ {print $2}' "$ENV_FILE") + backup_proxy_url=$(awk -F'=' '/^BACKUP_PROXY_URL=/ {print substr($0, index($0,"=")+1); exit}' "$ENV_FILE") + backup_proxy_url=$(echo "$backup_proxy_url" | sed -e 's/^"//' -e 's/"$//') + [ -z "$backup_proxy_enabled" ] && backup_proxy_enabled="false" + local masked_telegram_bot_key="" + + if [[ "$cron_schedule" == "0 0 * * *" ]]; then + interval_hours=24 + else + interval_hours=$(echo "$cron_schedule" | grep -oP '(?<=\*/)[0-9]+') + fi + + masked_telegram_bot_key=$(mask_telegram_bot_key "$telegram_bot_key") + + colorized_echo green "=====================================" + colorized_echo green "Current Backup Configuration:" + colorized_echo cyan "Telegram Bot API Key: $masked_telegram_bot_key" + colorized_echo cyan "Telegram Chat ID: $telegram_chat_id" + colorized_echo cyan "Backup Interval: Every $interval_hours hour(s)" + if [[ "$backup_proxy_enabled" == "true" && -n "$backup_proxy_url" ]]; then + colorized_echo cyan "Proxy: Enabled ($backup_proxy_url)" + else + colorized_echo cyan "Proxy: Disabled" + fi + colorized_echo green "=====================================" + echo "Choose an option:" + echo "1. Check Backup Service" + echo "2. Edit Backup Service" + echo "3. Reconfigure Backup Service" + echo "4. Remove Backup Service" + echo "5. Request Instant Backup" + echo "6. Exit" + read -p "Enter your choice (1-6): " user_choice + + case $user_choice in + 1) + view_backup_service + echo "" + ;; + 2) + edit_backup_service + echo "" + ;; + 3) + colorized_echo yellow "Starting reconfiguration..." + remove_backup_service + break + ;; + 4) + colorized_echo yellow "Removing Backup Service..." + remove_backup_service + return + ;; + 5) + colorized_echo yellow "Starting instant backup..." + backup_command + colorized_echo green "Instant backup completed." + echo "" + ;; + 6) + colorized_echo yellow "Exiting..." + return + ;; + *) + colorized_echo red "Invalid choice. Please try again." + echo "" + ;; + esac + done + else + colorized_echo yellow "No backup service is currently configured." + fi + + while true; do + printf "Enter your Telegram bot API key: " + read telegram_bot_key + if [[ -n "$telegram_bot_key" ]]; then + break + else + colorized_echo red "API key cannot be empty. Please try again." + fi + done + + while true; do + printf "Enter your Telegram chat ID: " + read telegram_chat_id + if [[ -n "$telegram_chat_id" ]]; then + break + else + colorized_echo red "Chat ID cannot be empty. Please try again." + fi + done + + while true; do + printf "Set up the backup interval in hours (1-24):\n" + read interval_hours + + if ! [[ "$interval_hours" =~ ^[0-9]+$ ]]; then + colorized_echo red "Invalid input. Please enter a valid number." + continue + fi + + if [[ "$interval_hours" -eq 24 ]]; then + cron_schedule="0 0 * * *" + colorized_echo green "Setting backup to run daily at midnight." + break + fi + + if [[ "$interval_hours" -ge 1 && "$interval_hours" -le 23 ]]; then + cron_schedule="0 */$interval_hours * * *" + colorized_echo green "Setting backup to run every $interval_hours hour(s)." + break + else + colorized_echo red "Invalid input. Please enter a number between 1-24." + fi + done + + while true; do + read -p "Do you need to use an HTTP/SOCKS proxy for Telegram backups? (y/N): " proxy_choice + case "$proxy_choice" in + [Yy]*) + backup_proxy_enabled="true" + break + ;; + [Nn]*|"") + backup_proxy_enabled="false" + break + ;; + *) + colorized_echo red "Invalid choice. Please enter y or n." + ;; + esac + done + + if [ "$backup_proxy_enabled" = "true" ]; then + while true; do + read -p "Enter proxy URL (e.g. http://127.0.0.1:8080 or socks5://127.0.0.1:1080): " backup_proxy_url + backup_proxy_url=$(echo "$backup_proxy_url" | xargs) + if [ -z "$backup_proxy_url" ]; then + colorized_echo red "Proxy URL cannot be empty." + continue + fi + if is_valid_proxy_url "$backup_proxy_url"; then + break + else + colorized_echo red "Invalid proxy URL. Supported prefixes: http://, https://, socks5://, socks5h://, socks4://." + fi + done + else + backup_proxy_url="" + fi + + sed -i '/^BACKUP_SERVICE_ENABLED/d' "$ENV_FILE" + sed -i '/^BACKUP_TELEGRAM_BOT_KEY/d' "$ENV_FILE" + sed -i '/^BACKUP_TELEGRAM_CHAT_ID/d' "$ENV_FILE" + sed -i '/^BACKUP_CRON_SCHEDULE/d' "$ENV_FILE" + sed -i '/^BACKUP_PROXY_ENABLED/d' "$ENV_FILE" + sed -i '/^BACKUP_PROXY_URL/d' "$ENV_FILE" + + { + echo "" + echo "# Backup service configuration" + echo "BACKUP_SERVICE_ENABLED=true" + echo "BACKUP_TELEGRAM_BOT_KEY=$telegram_bot_key" + echo "BACKUP_TELEGRAM_CHAT_ID=$telegram_chat_id" + echo "BACKUP_CRON_SCHEDULE=\"$cron_schedule\"" + echo "BACKUP_PROXY_ENABLED=$backup_proxy_enabled" + echo "BACKUP_PROXY_URL=\"$backup_proxy_url\"" + } >>"$ENV_FILE" + + colorized_echo green "Backup service configuration saved in $ENV_FILE." + + # Use full path to the script for cron job + local script_path="/usr/local/bin/$APP_NAME" + if [ ! -f "$script_path" ]; then + script_path=$(which "$APP_NAME" 2>/dev/null || echo "/usr/local/bin/$APP_NAME") + fi + # Set PATH for cron to ensure docker and other tools are found + local backup_command="PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin bash $script_path backup" + add_cron_job "$cron_schedule" "$backup_command" + + colorized_echo green "Backup service successfully configured." + + # Run initial backup + colorized_echo blue "Running initial backup..." + backup_command + if [ $? -eq 0 ]; then + colorized_echo green "Initial backup completed successfully." + else + colorized_echo yellow "Initial backup completed with warnings. Check logs if needed." + fi + if [[ "$interval_hours" -eq 24 ]]; then + colorized_echo cyan "Backups will be sent to Telegram daily (every 24 hours at midnight)." + else + colorized_echo cyan "Backups will be sent to Telegram every $interval_hours hour(s)." + fi + colorized_echo green "=====================================" +} + +add_cron_job() { + local schedule="$1" + local command="$2" + local temp_cron=$(mktemp) + local filtered_cron="${temp_cron}.tmp" + + crontab -l 2>/dev/null >"$temp_cron" || true + filter_backup_cron_entries "$temp_cron" "$filtered_cron" + mv "$filtered_cron" "$temp_cron" + echo "$schedule $command # pasarguard-backup-service" >>"$temp_cron" + + if crontab "$temp_cron"; then + colorized_echo green "Cron job successfully added." + else + colorized_echo red "Failed to add cron job. Please check manually." + fi + rm -f "$temp_cron" +} + +view_backup_service() { + if ! grep -q "BACKUP_SERVICE_ENABLED=true" "$ENV_FILE"; then + colorized_echo red "Backup service is not configured." + return 1 + fi + + local telegram_bot_key=$(awk -F'=' '/^BACKUP_TELEGRAM_BOT_KEY=/ {print $2}' "$ENV_FILE") + local telegram_chat_id=$(awk -F'=' '/^BACKUP_TELEGRAM_CHAT_ID=/ {print $2}' "$ENV_FILE") + local cron_schedule=$(awk -F'=' '/^BACKUP_CRON_SCHEDULE=/ {print $2}' "$ENV_FILE" | tr -d '"') + local backup_proxy_enabled=$(awk -F'=' '/^BACKUP_PROXY_ENABLED=/ {print $2}' "$ENV_FILE") + local backup_proxy_url=$(awk -F'=' '/^BACKUP_PROXY_URL=/ {print substr($0, index($0,"=")+1); exit}' "$ENV_FILE") + backup_proxy_url=$(echo "$backup_proxy_url" | sed -e 's/^"//' -e 's/"$//') + [ -z "$backup_proxy_enabled" ] && backup_proxy_enabled="false" + local interval_hours="" + local masked_telegram_bot_key="" + + if [[ "$cron_schedule" == "0 0 * * *" ]]; then + interval_hours=24 + else + interval_hours=$(echo "$cron_schedule" | grep -oP '(?<=\*/)[0-9]+') + fi + + masked_telegram_bot_key=$(mask_telegram_bot_key "$telegram_bot_key") + + colorized_echo blue "=====================================" + colorized_echo blue " Backup Service Details " + colorized_echo blue "=====================================" + colorized_echo green "Status: Enabled" + colorized_echo cyan "Telegram Bot API Key: $masked_telegram_bot_key" + colorized_echo cyan "Telegram Chat ID: $telegram_chat_id" + colorized_echo cyan "Cron Schedule: $cron_schedule" + if [[ "$interval_hours" -eq 24 ]]; then + colorized_echo cyan "Backup Interval: Daily at midnight (every 24 hours)" + else + colorized_echo cyan "Backup Interval: Every $interval_hours hour(s)" + fi + if [[ "$backup_proxy_enabled" == "true" && -n "$backup_proxy_url" ]]; then + colorized_echo cyan "Proxy: Enabled ($backup_proxy_url)" + else + colorized_echo cyan "Proxy: Disabled" + fi + colorized_echo blue "=====================================" + echo "" + read -p "Press Enter to continue..." +} + +edit_backup_service() { + if ! grep -q "BACKUP_SERVICE_ENABLED=true" "$ENV_FILE"; then + colorized_echo red "Backup service is not configured." + return 1 + fi + + local telegram_bot_key=$(awk -F'=' '/^BACKUP_TELEGRAM_BOT_KEY=/ {print $2}' "$ENV_FILE") + local telegram_chat_id=$(awk -F'=' '/^BACKUP_TELEGRAM_CHAT_ID=/ {print $2}' "$ENV_FILE") + local cron_schedule=$(awk -F'=' '/^BACKUP_CRON_SCHEDULE=/ {print $2}' "$ENV_FILE" | tr -d '"') + local backup_proxy_enabled=$(awk -F'=' '/^BACKUP_PROXY_ENABLED=/ {print $2}' "$ENV_FILE") + local backup_proxy_url=$(awk -F'=' '/^BACKUP_PROXY_URL=/ {print substr($0, index($0,"=")+1); exit}' "$ENV_FILE") + backup_proxy_url=$(echo "$backup_proxy_url" | sed -e 's/^"//' -e 's/"$//') + [ -z "$backup_proxy_enabled" ] && backup_proxy_enabled="false" + local interval_hours="" + local masked_telegram_bot_key="" + + if [[ "$cron_schedule" == "0 0 * * *" ]]; then + interval_hours=24 + else + interval_hours=$(echo "$cron_schedule" | grep -oP '(?<=\*/)[0-9]+') + fi + + masked_telegram_bot_key=$(mask_telegram_bot_key "$telegram_bot_key") + + colorized_echo blue "=====================================" + colorized_echo blue " Edit Backup Service " + colorized_echo blue "=====================================" + echo "Current configuration:" + local proxy_display="Disabled" + if [[ "$backup_proxy_enabled" == "true" && -n "$backup_proxy_url" ]]; then + proxy_display="Enabled ($backup_proxy_url)" + fi + colorized_echo cyan "1. Telegram Bot API Key: $masked_telegram_bot_key" + colorized_echo cyan "2. Telegram Chat ID: $telegram_chat_id" + colorized_echo cyan "3. Backup Interval: Every $interval_hours hour(s)" + colorized_echo cyan "4. Proxy: $proxy_display" + colorized_echo yellow "5. Cancel" + echo "" + read -p "Which setting would you like to edit? (1-5): " edit_choice + + case $edit_choice in + 1) + while true; do + printf "Enter new Telegram bot API key [current: %s]: " "$masked_telegram_bot_key" + read new_bot_key + if [[ -n "$new_bot_key" ]]; then + sed -i "s|^BACKUP_TELEGRAM_BOT_KEY=.*|BACKUP_TELEGRAM_BOT_KEY=$new_bot_key|" "$ENV_FILE" + colorized_echo green "Telegram Bot API Key updated successfully." + break + else + colorized_echo red "API key cannot be empty. Please try again." + fi + done + ;; + 2) + while true; do + printf "Enter new Telegram chat ID [current: $telegram_chat_id]: " + read new_chat_id + if [[ -n "$new_chat_id" ]]; then + sed -i "s|^BACKUP_TELEGRAM_CHAT_ID=.*|BACKUP_TELEGRAM_CHAT_ID=$new_chat_id|" "$ENV_FILE" + colorized_echo green "Telegram Chat ID updated successfully." + break + else + colorized_echo red "Chat ID cannot be empty. Please try again." + fi + done + ;; + 3) + while true; do + printf "Set new backup interval in hours (1-24) [current: $interval_hours]:\n" + read new_interval_hours + + if ! [[ "$new_interval_hours" =~ ^[0-9]+$ ]]; then + colorized_echo red "Invalid input. Please enter a valid number." + continue + fi + + local new_cron_schedule="" + if [[ "$new_interval_hours" -eq 24 ]]; then + new_cron_schedule="0 0 * * *" + colorized_echo green "Setting backup to run daily at midnight." + elif [[ "$new_interval_hours" -ge 1 && "$new_interval_hours" -le 23 ]]; then + new_cron_schedule="0 */$new_interval_hours * * *" + colorized_echo green "Setting backup to run every $new_interval_hours hour(s)." + else + colorized_echo red "Invalid input. Please enter a number between 1-24." + continue + fi + + sed -i "s|^BACKUP_CRON_SCHEDULE=.*|BACKUP_CRON_SCHEDULE=\"$new_cron_schedule\"|" "$ENV_FILE" + + # Use full path to the script for cron job + local script_path="/usr/local/bin/$APP_NAME" + if [ ! -f "$script_path" ]; then + script_path=$(which "$APP_NAME" 2>/dev/null || echo "/usr/local/bin/$APP_NAME") + fi + # Set PATH for cron to ensure docker and other tools are found + local backup_command="PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin bash $script_path backup" + local temp_cron=$(mktemp) + local filtered_cron="${temp_cron}.tmp" + crontab -l 2>/dev/null >"$temp_cron" || true + filter_backup_cron_entries "$temp_cron" "$filtered_cron" + mv "$filtered_cron" "$temp_cron" + echo "$new_cron_schedule $backup_command # pasarguard-backup-service" >>"$temp_cron" + + if crontab "$temp_cron"; then + colorized_echo green "Backup interval and cron schedule updated successfully." + else + colorized_echo red "Failed to update cron job. Please check manually." + fi + rm -f "$temp_cron" + break + done + ;; + 4) + local new_proxy_enabled="$backup_proxy_enabled" + local new_proxy_url="$backup_proxy_url" + while true; do + read -p "Enable proxy for Telegram backups? (y/N) [current: $proxy_display]: " proxy_choice + case "$proxy_choice" in + [Yy]*) + new_proxy_enabled="true" + break + ;; + [Nn]*|"") + new_proxy_enabled="false" + break + ;; + *) + colorized_echo red "Invalid choice. Please enter y or n." + ;; + esac + done + + if [ "$new_proxy_enabled" = "true" ]; then + while true; do + read -p "Enter proxy URL (e.g. http://127.0.0.1:8080 or socks5://127.0.0.1:1080) [current: $backup_proxy_url]: " input_proxy_url + if [ -z "$input_proxy_url" ]; then + if [ -n "$backup_proxy_url" ]; then + input_proxy_url="$backup_proxy_url" + else + colorized_echo red "Proxy URL cannot be empty." + continue + fi + fi + input_proxy_url=$(echo "$input_proxy_url" | xargs) + if is_valid_proxy_url "$input_proxy_url"; then + new_proxy_url="$input_proxy_url" + break + else + colorized_echo red "Invalid proxy URL. Supported prefixes: http://, https://, socks5://, socks5h://, socks4://." + fi + done + else + new_proxy_url="" + fi + + replace_or_append_env_var "BACKUP_PROXY_ENABLED" "$new_proxy_enabled" + replace_or_append_env_var "BACKUP_PROXY_URL" "$new_proxy_url" true + colorized_echo green "Backup proxy configuration updated successfully." + ;; + 5) + colorized_echo yellow "Edit cancelled." + return + ;; + *) + colorized_echo red "Invalid choice." + return + ;; + esac + + colorized_echo green "Backup service configuration updated successfully." +} + +remove_backup_service() { + colorized_echo red "in process..." + + sed -i '/^# Backup service configuration/d' "$ENV_FILE" + sed -i '/BACKUP_SERVICE_ENABLED/d' "$ENV_FILE" + sed -i '/BACKUP_TELEGRAM_BOT_KEY/d' "$ENV_FILE" + sed -i '/BACKUP_TELEGRAM_CHAT_ID/d' "$ENV_FILE" + sed -i '/BACKUP_CRON_SCHEDULE/d' "$ENV_FILE" + sed -i '/BACKUP_PROXY_ENABLED/d' "$ENV_FILE" + sed -i '/BACKUP_PROXY_URL/d' "$ENV_FILE" + + local temp_cron=$(mktemp) + local filtered_cron="${temp_cron}.tmp" + crontab -l 2>/dev/null >"$temp_cron" || true + + filter_backup_cron_entries "$temp_cron" "$filtered_cron" + mv "$filtered_cron" "$temp_cron" + + if crontab "$temp_cron"; then + colorized_echo green "Backup service task removed from crontab." + else + colorized_echo red "Failed to update crontab. Please check manually." + fi + + rm -f "$temp_cron" + + colorized_echo green "Backup service has been removed." +} + + +backup_command() { + colorized_echo blue "Starting backup process..." + + # Check if pasarguard is installed + if ! is_pasarguard_installed; then + colorized_echo red "pasarguard is not installed!" + return 1 + fi + + local backup_dir="$APP_DIR/backup" + local timestamp=$(date +"%Y%m%d%H%M%S") + local backup_file="$backup_dir/backup_$timestamp.zip" + local error_messages=() + local final_backup_paths=() + local split_size_arg="47m" # keep Telegram chunks under 50MB + local temp_dir="" + local log_file="" + local lock_file="${TMPDIR:-/tmp}/${APP_NAME}-backup.lock" + local lock_dir="${TMPDIR:-/tmp}/${APP_NAME}-backup.lock.d" + local lock_fd=9 + local keep_log_file=false + + mkdir -p "$backup_dir" + + if ! temp_dir=$(mktemp -d "${TMPDIR:-/tmp}/pasarguard_backup.XXXXXX"); then + colorized_echo red "Failed to create backup temp directory." + return 1 + fi + + if ! log_file=$(mktemp "${TMPDIR:-/tmp}/pasarguard_backup_error.XXXXXX.log"); then + colorized_echo red "Failed to create backup log file." + rm -rf "$temp_dir" + return 1 + fi + + if command -v flock >/dev/null 2>&1; then + eval "exec ${lock_fd}>\"$lock_file\"" + if ! flock -n "$lock_fd"; then + colorized_echo yellow "Another backup process is already running." + rm -rf "$temp_dir" + rm -f "$log_file" + return 1 + fi + elif ! mkdir "$lock_dir" 2>/dev/null; then + colorized_echo yellow "Another backup process is already running." + rm -rf "$temp_dir" + rm -f "$log_file" + return 1 + else + printf '%s\n' "$$" >"$lock_dir/pid" + fi + + cleanup_backup_command() { + trap - RETURN + rm -rf "$temp_dir" + if [ "$keep_log_file" != true ] && [ -n "$log_file" ]; then + rm -f "$log_file" + fi + if command -v flock >/dev/null 2>&1; then + eval "exec ${lock_fd}>&-" + else + rm -rf "$lock_dir" + fi + } + + trap cleanup_backup_command RETURN + + >"$log_file" + echo "Backup Log - $(date)" >>"$log_file" + + colorized_echo blue "Reading environment configuration..." + + if ! command -v rsync >/dev/null 2>&1; then + detect_os + install_package rsync + fi + + if ! command -v zip >/dev/null 2>&1; then + detect_os + install_package zip + fi + + if [ -f "$ENV_FILE" ]; then + while IFS='=' read -r key value; do + if [[ -z "$key" || "$key" =~ ^# ]]; then + continue + fi + key=$(echo "$key" | xargs) + value=$(echo "$value" | xargs) + # Remove surrounding quotes from value if present + value=$(echo "$value" | sed -E 's/^["'\''](.*)["'\'']$/\1/') + if [[ "$key" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]]; then + export "$key"="$value" + else + echo "Skipping invalid line in .env: $key=$value" >>"$log_file" + fi + done <"$ENV_FILE" + else + error_messages+=("Environment file (.env) not found.") + echo "Environment file (.env) not found." >>"$log_file" + send_backup_error_to_telegram "${error_messages[*]}" "$log_file" + keep_log_file=true + return 1 + fi + + local db_type="" + local sqlite_file="" + local db_host="" + local db_port="" + local db_user="" + local db_password="" + local db_name="" + local container_name="" + local safe_sqlalchemy_url="" + + safe_sqlalchemy_url=$(printf '%s' "${SQLALCHEMY_DATABASE_URL:-not set}" | sed -E 's#^([^:]+://)([^@/]+)@#\1REDACTED@#') + + # SQLALCHEMY_DATABASE_URL should already be loaded from .env above + # Just log what we have + echo "SQLALCHEMY_DATABASE_URL from environment: ${safe_sqlalchemy_url}" >>"$log_file" + + if [ -z "$SQLALCHEMY_DATABASE_URL" ]; then + colorized_echo red "Error: SQLALCHEMY_DATABASE_URL not found in .env file or not set" + echo "Please check $ENV_FILE for SQLALCHEMY_DATABASE_URL" >>"$log_file" + error_messages+=("SQLALCHEMY_DATABASE_URL not found in .env file") + colorized_echo yellow "Please check the log file for details: $log_file" + return 1 + fi + + if [ -n "$SQLALCHEMY_DATABASE_URL" ]; then + echo "Parsing SQLALCHEMY_DATABASE_URL: ${safe_sqlalchemy_url}" >>"$log_file" + + # Extract database type from scheme + if [[ "$SQLALCHEMY_DATABASE_URL" =~ ^sqlite ]]; then + db_type="sqlite" + # Extract SQLite file path + # SQLite URLs: sqlite:///relative/path or sqlite:////absolute/path + local sqlite_url_part="${SQLALCHEMY_DATABASE_URL#*://}" + sqlite_url_part="${sqlite_url_part%%\?*}" + sqlite_url_part="${sqlite_url_part%%#*}" + + # SQLite URL format: + # sqlite:////absolute/path (4 slashes = absolute path /path) + # After removing 'sqlite://', //absolute/path remains, convert to /absolute/path + if [[ "$sqlite_url_part" =~ ^//(.*)$ ]]; then + # Absolute path: sqlite:////absolute/path -> /absolute/path + sqlite_file="/${BASH_REMATCH[1]}" + elif [[ "$sqlite_url_part" =~ ^/(.*)$ ]]; then + # Could be absolute (sqlite:///path) or relative depending on context + # In practice, treat as absolute since SQLAlchemy uses 4 slashes for absolute + sqlite_file="/${BASH_REMATCH[1]}" + else + # Relative path (no leading slash) + sqlite_file="$sqlite_url_part" + fi + elif [[ "$SQLALCHEMY_DATABASE_URL" =~ ^(mysql|mariadb|postgresql)[^:]*:// ]]; then + # Extract scheme to determine type + if [[ "$SQLALCHEMY_DATABASE_URL" =~ ^mariadb[^:]*:// ]]; then + db_type="mariadb" + elif [[ "$SQLALCHEMY_DATABASE_URL" =~ ^mysql[^:]*:// ]]; then + db_type="mysql" + elif [[ "$SQLALCHEMY_DATABASE_URL" =~ ^postgresql[^:]*:// ]]; then + # Check if it's timescaledb by checking for specific patterns or container + if grep -q "image: timescale/timescaledb" "$COMPOSE_FILE" 2>/dev/null; then + db_type="timescaledb" + else + db_type="postgresql" + fi + fi + + # Parse connection string: scheme://[user[:password]@]host[:port]/database[?query] + # Remove scheme prefix + local url_part="${SQLALCHEMY_DATABASE_URL#*://}" + # Remove query parameters if present + url_part="${url_part%%\?*}" + url_part="${url_part%%#*}" + + # Extract auth part (user:password@) + if [[ "$url_part" =~ ^([^@]+)@(.+)$ ]]; then + local auth_part="${BASH_REMATCH[1]}" + url_part="${BASH_REMATCH[2]}" + + # Extract username and password + if [[ "$auth_part" =~ ^([^:]+):(.+)$ ]]; then + db_user="${BASH_REMATCH[1]}" + db_password="${BASH_REMATCH[2]}" + else + db_user="$auth_part" + fi + fi + + # Extract host, port, and database + if [[ "$url_part" =~ ^([^:/]+)(:([0-9]+))?/(.+)$ ]]; then + db_host="${BASH_REMATCH[1]}" + db_port="${BASH_REMATCH[3]:-}" + db_name="${BASH_REMATCH[4]}" + + # Remove query parameters from database name if any + db_name="${db_name%%\?*}" + db_name="${db_name%%#*}" + + # Set default ports if not specified + if [ -z "$db_port" ]; then + if [[ "$db_type" =~ ^(mysql|mariadb)$ ]]; then + db_port="3306" + elif [[ "$db_type" =~ ^(postgresql|timescaledb)$ ]]; then + db_port="5432" + fi + fi + fi + + # For local databases, try to find container name from docker-compose + if [[ "$db_host" == "127.0.0.1" || "$db_host" == "localhost" || "$db_host" == "::1" ]]; then + container_name=$(find_container "$db_type") + echo "Container name/ID for $db_type: $container_name" >>"$log_file" + fi + fi + fi + + if [ -n "$db_type" ]; then + echo "Database detected: $db_type" >>"$log_file" + echo "Database host: ${db_host:-localhost}" >>"$log_file" + colorized_echo blue "Database detected: $db_type" + colorized_echo blue "Backing up database..." + case $db_type in + mariadb) + if [[ "$db_host" == "127.0.0.1" || "$db_host" == "localhost" || "$db_host" == "::1" ]]; then + if [ -z "$container_name" ]; then + colorized_echo red "Error: MariaDB container not found. Is the container running?" + echo "MariaDB container not found. Container name: ${container_name:-empty}" >>"$log_file" + error_messages+=("MariaDB container not found or not running.") + else + local verified_container=$(check_container "$container_name" "$db_type") + if [ -z "$verified_container" ]; then + colorized_echo red "Error: MariaDB container not found or not running." + echo "Container not found or not running: $container_name" >>"$log_file" + error_messages+=("MariaDB container not found or not running.") + else + container_name="$verified_container" + # Local Docker container + # Try root user with MYSQL_ROOT_PASSWORD first for all databases backup + if [ -n "${MYSQL_ROOT_PASSWORD:-}" ]; then + colorized_echo blue "Backing up all MariaDB databases from container: $container_name (using root user)" + if docker exec "$container_name" mariadb-dump -u root -p"$MYSQL_ROOT_PASSWORD" --all-databases --ignore-database=mysql --ignore-database=performance_schema --ignore-database=information_schema --ignore-database=sys --events --triggers >"$temp_dir/db_backup.sql" 2>>"$log_file"; then + colorized_echo green "MariaDB backup completed successfully (all databases)" + else + # Fallback to SQL URL credentials for specific database + colorized_echo yellow "Root backup failed, falling back to app user for specific database" + local backup_user="${db_user:-${DB_USER:-}}" + local backup_password="${db_password:-${DB_PASSWORD:-}}" + + if [ -z "$backup_password" ] || [ -z "$db_name" ]; then + colorized_echo red "Error: Cannot fallback - missing database name or password in SQLALCHEMY_DATABASE_URL" + error_messages+=("MariaDB backup failed - root backup failed and fallback credentials incomplete.") + else + colorized_echo blue "Backing up MariaDB database '$db_name' from container: $container_name (using app user)" + if ! docker exec "$container_name" mariadb-dump -u "$backup_user" -p"$backup_password" "$db_name" --events --triggers >"$temp_dir/db_backup.sql" 2>>"$log_file"; then + colorized_echo red "MariaDB dump failed. Check log file for details." + error_messages+=("MariaDB dump failed.") + else + colorized_echo green "MariaDB backup completed successfully" + fi + fi + fi + else + # No MYSQL_ROOT_PASSWORD, use SQL URL credentials for specific database + local backup_user="${db_user:-${DB_USER:-}}" + local backup_password="${db_password:-${DB_PASSWORD:-}}" + + if [ -z "$backup_password" ]; then + colorized_echo red "Error: Database password not found. Check MYSQL_ROOT_PASSWORD or SQLALCHEMY_DATABASE_URL in .env" + error_messages+=("MariaDB password not found.") + elif [ -z "$db_name" ]; then + colorized_echo red "Error: Database name not found in SQLALCHEMY_DATABASE_URL" + error_messages+=("MariaDB database name not found.") + else + colorized_echo blue "Backing up MariaDB database '$db_name' from container: $container_name (using app user)" + if ! docker exec "$container_name" mariadb-dump -u "$backup_user" -p"$backup_password" "$db_name" --events --triggers >"$temp_dir/db_backup.sql" 2>>"$log_file"; then + colorized_echo red "MariaDB dump failed. Check log file for details." + error_messages+=("MariaDB dump failed.") + else + colorized_echo green "MariaDB backup completed successfully" + fi + fi + fi + fi + fi + else + # Remote database - would need mariadb-client installed + colorized_echo red "Remote MariaDB backup not yet supported. Please use local database or install mariadb-client." + error_messages+=("Remote MariaDB backup not yet supported. Please use local database or install mariadb-client.") + fi + ;; + mysql) + if [[ "$db_host" == "127.0.0.1" || "$db_host" == "localhost" || "$db_host" == "::1" ]]; then + if [ -z "$container_name" ]; then + colorized_echo red "Error: MySQL container not found. Is the container running?" + echo "MySQL container not found. Container name: ${container_name:-empty}" >>"$log_file" + error_messages+=("MySQL container not found or not running.") + else + local verified_container=$(check_container "$container_name" "$db_type") + if [ -z "$verified_container" ]; then + colorized_echo red "Error: MySQL/MariaDB container not found or not running." + echo "Container not found or not running: $container_name" >>"$log_file" + error_messages+=("MySQL/MariaDB container not found or not running.") + else + container_name="$verified_container" + # Check if this is actually a MariaDB container (try mariadb-dump first) + local is_mariadb=false + if docker exec "$container_name" mariadb-dump --version >/dev/null 2>&1; then + is_mariadb=true + fi + + # Local Docker container + # Try root user with MYSQL_ROOT_PASSWORD first for all databases backup + if [ -n "${MYSQL_ROOT_PASSWORD:-}" ]; then + # Choose command based on whether it's MariaDB or MySQL + local mysql_cmd="mysql" + local dump_cmd="mysqldump" + local db_type_name="MySQL" + if [ "$is_mariadb" = true ]; then + mysql_cmd="mariadb" + dump_cmd="mariadb-dump" + db_type_name="MariaDB" + fi + + colorized_echo blue "Backing up all $db_type_name databases from container: $container_name (using root user)" + databases=$(docker exec "$container_name" "$mysql_cmd" -u root -p"$MYSQL_ROOT_PASSWORD" -e "SHOW DATABASES;" 2>>"$log_file" | grep -Ev "^(Database|mysql|performance_schema|information_schema|sys)$" || true) + if [ -z "$databases" ]; then + colorized_echo yellow "No user databases found, falling back to specific database backup" + # Fallback to SQL URL credentials + local backup_user="${db_user:-${DB_USER:-}}" + local backup_password="${db_password:-${DB_PASSWORD:-}}" + + if [ -z "$backup_password" ] || [ -z "$db_name" ]; then + colorized_echo red "Error: Cannot fallback - missing database name or password in SQLALCHEMY_DATABASE_URL" + error_messages+=("MySQL backup failed - no databases found and fallback credentials incomplete.") + else + colorized_echo blue "Backing up $db_type_name database '$db_name' from container: $container_name (using app user)" + if ! docker exec "$container_name" "$dump_cmd" -u "$backup_user" -p"$backup_password" "$db_name" --events --triggers >"$temp_dir/db_backup.sql" 2>>"$log_file"; then + colorized_echo red "$db_type_name dump failed. Check log file for details." + error_messages+=("$db_type_name dump failed.") + else + colorized_echo green "$db_type_name backup completed successfully" + fi + fi + elif ! docker exec "$container_name" "$dump_cmd" -u root -p"$MYSQL_ROOT_PASSWORD" --databases $databases --events --triggers >"$temp_dir/db_backup.sql" 2>>"$log_file"; then + # Root backup failed, fallback to SQL URL credentials + colorized_echo yellow "Root backup failed, falling back to app user for specific database" + local backup_user="${db_user:-${DB_USER:-}}" + local backup_password="${db_password:-${DB_PASSWORD:-}}" + + if [ -z "$backup_password" ] || [ -z "$db_name" ]; then + colorized_echo red "Error: Cannot fallback - missing database name or password in SQLALCHEMY_DATABASE_URL" + error_messages+=("MySQL backup failed - root backup failed and fallback credentials incomplete.") + else + colorized_echo blue "Backing up $db_type_name database '$db_name' from container: $container_name (using app user)" + if ! docker exec "$container_name" "$dump_cmd" -u "$backup_user" -p"$backup_password" "$db_name" --events --triggers >"$temp_dir/db_backup.sql" 2>>"$log_file"; then + colorized_echo red "$db_type_name dump failed. Check log file for details." + error_messages+=("$db_type_name dump failed.") + else + colorized_echo green "$db_type_name backup completed successfully" + fi + fi + else + colorized_echo green "$db_type_name backup completed successfully (all databases)" + fi + else + # No MYSQL_ROOT_PASSWORD, use SQL URL credentials for specific database + local backup_user="${db_user:-${DB_USER:-}}" + local backup_password="${db_password:-${DB_PASSWORD:-}}" + local dump_cmd="mysqldump" + local db_type_name="MySQL" + if [ "$is_mariadb" = true ]; then + dump_cmd="mariadb-dump" + db_type_name="MariaDB" + fi + + if [ -z "$backup_password" ]; then + colorized_echo red "Error: Database password not found. Check MYSQL_ROOT_PASSWORD or SQLALCHEMY_DATABASE_URL in .env" + error_messages+=("MySQL password not found.") + elif [ -z "$db_name" ]; then + colorized_echo red "Error: Database name not found in SQLALCHEMY_DATABASE_URL" + error_messages+=("MySQL database name not found.") + else + colorized_echo blue "Backing up $db_type_name database '$db_name' from container: $container_name (using app user)" + if ! docker exec "$container_name" "$dump_cmd" -u "$backup_user" -p"$backup_password" "$db_name" --events --triggers >"$temp_dir/db_backup.sql" 2>>"$log_file"; then + colorized_echo red "$db_type_name dump failed. Check log file for details." + error_messages+=("$db_type_name dump failed.") + else + colorized_echo green "$db_type_name backup completed successfully" + fi + fi + fi + fi + fi + else + # Remote database - would need mysql-client installed + colorized_echo red "Remote MySQL backup not yet supported. Please use local database or install mysql-client." + error_messages+=("Remote MySQL backup not yet supported. Please use local database or install mysql-client.") + fi + ;; + postgresql) + if [[ "$db_host" == "127.0.0.1" || "$db_host" == "localhost" || "$db_host" == "::1" ]]; then + if [ -z "$container_name" ]; then + colorized_echo red "Error: PostgreSQL container not found. Is the container running?" + echo "PostgreSQL container not found. Container name: ${container_name:-empty}" >>"$log_file" + error_messages+=("PostgreSQL container not found or not running.") + else + local verified_container=$(check_container "$container_name" "$db_type") + if [ -z "$verified_container" ]; then + colorized_echo red "Error: PostgreSQL container not found or not running." + echo "Container not found or not running: $container_name" >>"$log_file" + error_messages+=("PostgreSQL container not found or not running.") + else + container_name="$verified_container" + # Local Docker container + # Try postgres superuser with DB_PASSWORD first for pg_dumpall (all databases) + if [ -n "${DB_PASSWORD:-}" ]; then + colorized_echo blue "Backing up all PostgreSQL databases from container: $container_name (using postgres superuser)" + export PGPASSWORD="$DB_PASSWORD" + if docker exec "$container_name" pg_dumpall -U postgres >"$temp_dir/db_backup.sql" 2>>"$log_file"; then + colorized_echo green "PostgreSQL backup completed successfully (all databases)" + unset PGPASSWORD + else + # Fallback to pg_dump with SQL URL credentials + unset PGPASSWORD + colorized_echo yellow "pg_dumpall failed, falling back to pg_dump for specific database" + local backup_user="${db_user:-${DB_USER:-postgres}}" + local backup_password="${db_password:-${DB_PASSWORD:-}}" + + if [ -z "$backup_password" ] || [ -z "$db_name" ]; then + colorized_echo red "Error: Cannot fallback - missing database name or password in SQLALCHEMY_DATABASE_URL" + error_messages+=("PostgreSQL backup failed - pg_dumpall failed and fallback credentials incomplete.") + else + colorized_echo blue "Backing up PostgreSQL database '$db_name' from container: $container_name (using app user)" + export PGPASSWORD="$backup_password" + if ! docker exec "$container_name" pg_dump -U "$backup_user" -d "$db_name" --clean --if-exists >"$temp_dir/db_backup.sql" 2>>"$log_file"; then + colorized_echo red "PostgreSQL dump failed. Check log file for details." + error_messages+=("PostgreSQL dump failed.") + else + colorized_echo green "PostgreSQL backup completed successfully" + fi + unset PGPASSWORD + fi + fi + else + # No DB_PASSWORD, use SQL URL credentials for pg_dump + local backup_user="${db_user:-${DB_USER:-postgres}}" + local backup_password="${db_password:-${DB_PASSWORD:-}}" + + if [ -z "$backup_password" ]; then + colorized_echo red "Error: Database password not found. Check DB_PASSWORD or SQLALCHEMY_DATABASE_URL in .env" + error_messages+=("PostgreSQL password not found.") + elif [ -z "$db_name" ]; then + colorized_echo red "Error: Database name not found in SQLALCHEMY_DATABASE_URL" + error_messages+=("PostgreSQL database name not found.") + else + colorized_echo blue "Backing up PostgreSQL database '$db_name' from container: $container_name (using app user)" + export PGPASSWORD="$backup_password" + if ! docker exec "$container_name" pg_dump -U "$backup_user" -d "$db_name" --clean --if-exists >"$temp_dir/db_backup.sql" 2>>"$log_file"; then + colorized_echo red "PostgreSQL dump failed. Check log file for details." + error_messages+=("PostgreSQL dump failed.") + else + colorized_echo green "PostgreSQL backup completed successfully" + fi + unset PGPASSWORD + fi + fi + fi + fi + else + # Remote database - would need postgresql-client installed + colorized_echo red "Remote PostgreSQL backup not yet supported. Please use local database or install postgresql-client." + error_messages+=("Remote PostgreSQL backup not yet supported. Please use local database or install postgresql-client.") + fi + ;; + timescaledb) + if [[ "$db_host" == "127.0.0.1" || "$db_host" == "localhost" || "$db_host" == "::1" ]]; then + if [ -z "$container_name" ]; then + colorized_echo red "Error: TimescaleDB container not found. Is the container running?" + echo "Container name detection failed. Checked for: timescaledb, postgresql" >>"$log_file" + error_messages+=("TimescaleDB container not found or not running.") + else + # Get actual container name/ID - ps -q returns container ID, which is what we need + # But first verify the container exists + local actual_container="" + if docker inspect "$container_name" >/dev/null 2>&1; then + actual_container="$container_name" + else + # Try to find container by service name using docker compose + actual_container=$($COMPOSE -f "$COMPOSE_FILE" -p "$APP_NAME" ps -q timescaledb 2>/dev/null) + if [ -z "$actual_container" ]; then + actual_container=$($COMPOSE -f "$COMPOSE_FILE" -p "$APP_NAME" ps -q postgresql 2>/dev/null) + fi + if [ -z "$actual_container" ]; then + # Try with full container name pattern + local full_container_name="${APP_NAME}-timescaledb-1" + if docker inspect "$full_container_name" >/dev/null 2>&1; then + actual_container="$full_container_name" + else + full_container_name="${APP_NAME}-postgresql-1" + if docker inspect "$full_container_name" >/dev/null 2>&1; then + actual_container="$full_container_name" + fi + fi + fi + fi + + if [ -z "$actual_container" ]; then + colorized_echo red "Error: TimescaleDB container not found. Is the container running?" + echo "Container not found. Tried: $container_name and various patterns" >>"$log_file" + error_messages+=("TimescaleDB container not found or not running.") + else + container_name="$actual_container" + # Local Docker container + # Use SQL URL credentials directly for pg_dump (more reliable than pg_dumpall) + local backup_user="${db_user:-${DB_USER:-postgres}}" + local backup_password="${db_password:-${DB_PASSWORD:-}}" + + if [ -z "$backup_password" ]; then + colorized_echo red "Error: Database password not found. Check DB_PASSWORD or SQLALCHEMY_DATABASE_URL in .env" + error_messages+=("TimescaleDB password not found.") + elif [ -z "$db_name" ]; then + colorized_echo red "Error: Database name not found in SQLALCHEMY_DATABASE_URL" + error_messages+=("TimescaleDB database name not found.") + else + colorized_echo blue "Backing up TimescaleDB database '$db_name' from container: $container_name (using user: $backup_user)" + export PGPASSWORD="$backup_password" + if ! docker exec "$container_name" pg_dump -U "$backup_user" -d "$db_name" --clean --if-exists >"$temp_dir/db_backup.sql" 2>>"$log_file"; then + colorized_echo red "TimescaleDB dump failed. Check log file for details: $log_file" + error_messages+=("TimescaleDB dump failed for database '$db_name'.") + else + colorized_echo green "TimescaleDB backup completed successfully" + fi + unset PGPASSWORD + fi + fi + fi + else + # Remote database - would need postgresql-client installed + colorized_echo red "Remote TimescaleDB backup not yet supported. Please use local database or install postgresql-client." + error_messages+=("Remote TimescaleDB backup not yet supported. Please use local database or install postgresql-client.") + fi + ;; + sqlite) + if [ -f "$sqlite_file" ]; then + if ! cp "$sqlite_file" "$temp_dir/db_backup.sqlite" 2>>"$log_file"; then + error_messages+=("Failed to copy SQLite database.") + fi + else + error_messages+=("SQLite database file not found at $sqlite_file.") + fi + ;; + esac + else + colorized_echo yellow "Warning: No database type detected. Skipping database backup." + echo "Warning: No database type detected." >>"$log_file" + echo "SQLALCHEMY_DATABASE_URL: ${safe_sqlalchemy_url}" >>"$log_file" + fi + + colorized_echo blue "Copying configuration files..." + if ! cp "$APP_DIR/.env" "$temp_dir/" 2>>"$log_file"; then + error_messages+=("Failed to copy .env file.") + echo "Failed to copy .env file" >>"$log_file" + fi + if ! cp "$APP_DIR/docker-compose.yml" "$temp_dir/" 2>>"$log_file"; then + error_messages+=("Failed to copy docker-compose.yml file.") + echo "Failed to copy docker-compose.yml file" >>"$log_file" + fi + + colorized_echo blue "Copying data directory..." + # Ensure destination directory exists and is empty (already cleaned above, but be explicit) + if [ -d "$DATA_DIR" ]; then + if ! rsync -av --exclude 'xray-core' --exclude 'mysql' "$DATA_DIR/" "$temp_dir/pasarguard_data/" >>"$log_file" 2>&1; then + error_messages+=("Failed to copy data directory.") + echo "Failed to copy data directory" >>"$log_file" + fi + else + colorized_echo yellow "Data directory $DATA_DIR does not exist. Skipping data directory backup." + echo "Data directory $DATA_DIR does not exist. Skipping." >>"$log_file" + # Create empty directory structure so tar doesn't fail + mkdir -p "$temp_dir/pasarguard_data" + fi + + # Remove Unix socket files so zip doesn't fail with ENXIO ("No such device or address") + if [ -d "$temp_dir" ]; then + local socket_files + socket_files=$(find "$temp_dir" -type s -print 2>/dev/null || true) + if [ -n "$socket_files" ]; then + colorized_echo yellow "Removing Unix socket files before archiving (zip cannot archive sockets)." + printf "%s\n" "$socket_files" >>"$log_file" + find "$temp_dir" -type s -delete >>"$log_file" 2>&1 || true + fi + fi + + colorized_echo blue "Creating backup archive..." + # Verify temp_dir exists and has content before creating archive + if [ ! -d "$temp_dir" ] || [ -z "$(ls -A "$temp_dir" 2>/dev/null)" ]; then + error_messages+=("Temporary directory is empty or missing. Cannot create archive.") + echo "Temporary directory is empty or missing: $temp_dir" >>"$log_file" + elif ! (cd "$temp_dir" && zip -rq -s "$split_size_arg" "$backup_file" .) 2>>"$log_file"; then + error_messages+=("Failed to create backup archive.") + echo "Failed to create backup archive." >>"$log_file" + else + find "$backup_dir" -maxdepth 1 -type f \ + \( -name "backup_*.tar.gz" -o -name "backup_*.zip" -o -name "backup_*.z[0-9][0-9]" \) \ + ! -name "backup_${timestamp}.zip" \ + ! -name "backup_${timestamp}.z[0-9][0-9]" \ + -delete 2>/dev/null || true + local backup_size=$(du -h "$backup_file" | cut -f1) + colorized_echo green "Backup archive created: $backup_file (Size: $backup_size)" + fi + + if [ -f "$backup_file" ]; then + while IFS= read -r file; do + final_backup_paths+=("$file") + done < <(find "$backup_dir" -maxdepth 1 -type f -name "backup_${timestamp}.z[0-9][0-9]" | sort) + final_backup_paths+=("$backup_file") + fi + + if [ ${#error_messages[@]} -gt 0 ]; then + keep_log_file=true + colorized_echo red "Backup completed with errors:" + for error in "${error_messages[@]}"; do + colorized_echo red " - $error" + done + colorized_echo yellow "Check log file: $log_file" + if [ -f "$ENV_FILE" ]; then + send_backup_error_to_telegram "${error_messages[*]}" "$log_file" + fi + return 1 + fi + + if [ ${#final_backup_paths[@]} -eq 0 ]; then + keep_log_file=true + colorized_echo red "Backup file was not created. Check log file: $log_file" + return 1 + fi + + if [ ${#final_backup_paths[@]} -eq 1 ]; then + colorized_echo green "Backup completed successfully: ${final_backup_paths[0]}" + else + colorized_echo green "Backup completed successfully in ${#final_backup_paths[@]} parts:" + for part in "${final_backup_paths[@]}"; do + colorized_echo green " - $(basename "$part")" + done + fi + if [ -f "$ENV_FILE" ]; then + send_backup_to_telegram "$backup_file" + fi +} diff --git a/lib/pasarguard-restore.sh b/lib/pasarguard-restore.sh new file mode 100644 index 0000000..052398d --- /dev/null +++ b/lib/pasarguard-restore.sh @@ -0,0 +1,908 @@ +#!/usr/bin/env bash + +restore_command() { + colorized_echo blue "Starting restore process..." + + # Check if pasarguard is installed + if ! is_pasarguard_installed; then + colorized_echo red "pasarguard's not installed!" + exit 1 + fi + + detect_compose + + if ! is_pasarguard_up; then + colorized_echo red "pasarguard is not up. Please start pasarguard first." + exit 1 + fi + + local current_db_user="" + local current_db_password="" + local current_db_name="" + local current_sqlalchemy_url="" + local current_mysql_root_password="" + + redact_database_url() { + local url="$1" + + if [ -z "$url" ]; then + printf '%s\n' "not set" + return 0 + fi + + printf '%s\n' "$url" | sed -E 's#^([^:]+://)([^@/]+)@#\1REDACTED@#' + } + + if [ -f "$ENV_FILE" ]; then + set +e + while IFS='=' read -r key value || [ -n "$key" ]; do + if [[ -z "$key" || "$key" =~ ^# ]]; then + continue + fi + key=$(echo "$key" | xargs 2>/dev/null || echo "$key" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') + value=$(echo "$value" | xargs 2>/dev/null || echo "$value" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') + value=$(echo "$value" | sed -E 's/^["'"'"'](.*)["'"'"']$/\1/' 2>/dev/null || echo "$value") + case "$key" in + MYSQL_ROOT_PASSWORD) + current_mysql_root_password="$value" + ;; + DB_USER) + current_db_user="$value" + ;; + DB_PASSWORD) + current_db_password="$value" + ;; + DB_NAME) + current_db_name="$value" + ;; + SQLALCHEMY_DATABASE_URL) + current_sqlalchemy_url="$value" + ;; + esac + done <"$ENV_FILE" + set -e + fi + + local backup_dir="$APP_DIR/backup" + local temp_restore_dir="/tmp/pasarguard_restore" + local log_file="/var/log/pasarguard_restore_error.log" + >"$log_file" + echo "Restore Log - $(date)" >>"$log_file" + + # Clean up temp directory + rm -rf "$temp_restore_dir" + mkdir -p "$temp_restore_dir" + + # Check if backup directory exists + if [ ! -d "$backup_dir" ]; then + colorized_echo red "Backup directory not found: $backup_dir" + exit 1 + fi + + # List available backup files (find all backup-related files in backup directory) + local backup_candidates=() + while IFS= read -r -d '' file; do + backup_candidates+=("$file") + done < <(find "$backup_dir" -maxdepth 1 \( -name "*backup*.gz" -o -name "*backup*.tar.gz" -o -name "*.tar.gz" -o -name "*backup*.zip" -o -name "*.zip" \) -type f -print0 2>/dev/null) + + if [ ${#backup_candidates[@]} -eq 0 ]; then + # Fallback: try to find any archive files + while IFS= read -r -d '' file; do + backup_candidates+=("$file") + done < <(find "$backup_dir" -maxdepth 1 \( -name "*.gz" -o -name "*.zip" \) -type f -print0 2>/dev/null) + fi + + local backup_files=() + for file in "${backup_candidates[@]}"; do + local filename=$(basename "$file") + if [[ "$filename" =~ \.part[0-9]{2}\.zip$ ]] && [[ ! "$filename" =~ \.part01\.zip$ ]]; then + continue + fi + if [[ "$filename" =~ \.z[0-9]{2}$ ]]; then + continue + fi + backup_files+=("$file") + done + + if [ ${#backup_files[@]} -eq 0 ]; then + colorized_echo red "No backup files found in $backup_dir" + colorized_echo yellow "Looking for files with extensions: .gz, .zip, .tar.gz or containing 'backup'" + exit 1 + fi + + colorized_echo blue "Available backup files:" + local i=1 + for file in "${backup_files[@]}"; do + if [ -f "$file" ]; then + local filename=$(basename "$file") + if [[ "$filename" =~ \.part[0-9]{2}\.zip$ ]]; then + local base_name="${filename%%.part*}" + local part_count=$(find "$backup_dir" -maxdepth 1 -type f -name "${base_name}.part*.zip" | wc -l | awk '{print $1}') + [ -z "$part_count" ] && part_count=0 + local total_size_bytes=0 + while IFS= read -r part_file; do + local part_size=$(stat -c%s "$part_file" 2>/dev/null || stat -f%z "$part_file" 2>/dev/null) + if [ -z "$part_size" ]; then + part_size=$(wc -c <"$part_file") + fi + total_size_bytes=$((total_size_bytes + part_size)) + done < <(find "$backup_dir" -maxdepth 1 -type f -name "${base_name}.part*.zip") + local human_size="" + if command -v numfmt >/dev/null 2>&1; then + human_size=$(numfmt --to=iec --suffix=B "$total_size_bytes" 2>/dev/null || awk -v size="$total_size_bytes" 'BEGIN { printf "%.2f MB", size/1048576 }') + else + human_size=$(awk -v size="$total_size_bytes" 'BEGIN { printf "%.2f MB", size/1048576 }') + fi + local file_date=$(date -r "$file" "+%Y-%m-%d %H:%M:%S") + echo "$i. $filename (Parts: ${part_count:-1}, Total Size: $human_size, Date: $file_date)" + elif [[ "$filename" =~ \.zip$ ]]; then + local base_name="${filename%.zip}" + local zip_part_files=() + while IFS= read -r part_file; do + zip_part_files+=("$part_file") + done < <(find "$backup_dir" -maxdepth 1 -type f -name "${base_name}.z[0-9][0-9]" | sort) + if [ ${#zip_part_files[@]} -gt 0 ]; then + local total_size_bytes=0 + for part_file in "${zip_part_files[@]}"; do + local part_size=$(stat -c%s "$part_file" 2>/dev/null || stat -f%z "$part_file" 2>/dev/null) + if [ -z "$part_size" ]; then + part_size=$(wc -c <"$part_file") + fi + total_size_bytes=$((total_size_bytes + part_size)) + done + local main_size=$(stat -c%s "$file" 2>/dev/null || stat -f%z "$file" 2>/dev/null) + if [ -z "$main_size" ]; then + main_size=$(wc -c <"$file") + fi + total_size_bytes=$((total_size_bytes + main_size)) + local part_display="" + if command -v numfmt >/dev/null 2>&1; then + part_display=$(numfmt --to=iec --suffix=B "$total_size_bytes" 2>/dev/null || awk -v size="$total_size_bytes" 'BEGIN { printf "%.2f MB", size/1048576 }') + else + part_display=$(awk -v size="$total_size_bytes" 'BEGIN { printf "%.2f MB", size/1048576 }') + fi + local file_date=$(date -r "$file" "+%Y-%m-%d %H:%M:%S") + local part_count=$(( ${#zip_part_files[@]} + 1 )) + echo "$i. $filename (Zip splits: $part_count parts, Total Size: $part_display, Date: $file_date)" + else + local file_size=$(du -h "$file" | cut -f1) + local file_date=$(date -r "$file" "+%Y-%m-%d %H:%M:%S") + echo "$i. $filename (Size: $file_size, Date: $file_date)" + fi + else + local file_size=$(du -h "$file" | cut -f1) + local file_date=$(date -r "$file" "+%Y-%m-%d %H:%M:%S") + echo "$i. $filename (Size: $file_size, Date: $file_date)" + fi + ((i++)) + fi + done + + local file_count=$((i-1)) + if [ "$file_count" -eq 0 ]; then + colorized_echo red "No valid backup files found." + exit 1 + fi + + # Select backup file + while true; do + printf "Select backup file to restore from (1-%d): " "$file_count" + read -r selection + if [[ "$selection" =~ ^[0-9]+$ ]] && [ "$selection" -ge 1 ] && [ "$selection" -le "$file_count" ]; then + break + else + colorized_echo red "Invalid selection. Please enter a number between 1 and $file_count." + fi + done + + local selected_file="${backup_files[$((selection-1))]}" + local selected_filename=$(basename "$selected_file") + + colorized_echo blue "Selected backup: $selected_filename" + + colorized_echo blue "Preparing archive for extraction..." + local archive_to_extract="$selected_file" + local archive_format="tar" + + if [[ "$selected_filename" =~ \.part[0-9]{2}\.zip$ ]]; then + archive_format="zip" + local base_name="${selected_filename%%.part*}" + colorized_echo yellow "Detected split zip backup. Checking available parts..." + if [ ! -f "$backup_dir/${base_name}.part01.zip" ]; then + colorized_echo red "Missing ${base_name}.part01.zip. Cannot restore split backup." + rm -rf "$temp_restore_dir" + exit 1 + fi + local concatenated_file="$temp_restore_dir/${base_name}_combined.zip" + >"$concatenated_file" + local part_count=0 + while IFS= read -r part_file; do + cat "$part_file" >>"$concatenated_file" + part_count=$((part_count + 1)) + done < <(find "$backup_dir" -maxdepth 1 -type f -name "${base_name}.part*.zip" | sort) + if [ "$part_count" -eq 0 ]; then + colorized_echo red "No parts found for $base_name" + rm -rf "$temp_restore_dir" + exit 1 + fi + archive_to_extract="$concatenated_file" + colorized_echo green "āœ“ Combined $part_count part(s)" + elif [[ "$selected_filename" =~ \.zip$ ]]; then + archive_format="zip" + local base_name="${selected_filename%.zip}" + local zip_split_parts=() + while IFS= read -r part_file; do + [ -n "$part_file" ] && zip_split_parts+=("$part_file") + done < <(find "$backup_dir" -maxdepth 1 -type f -name "${base_name}.z[0-9][0-9]" | sort) + + if [ ${#zip_split_parts[@]} -gt 0 ]; then + colorized_echo yellow "Detected split zip backup (.zXX + .zip). Rebuilding archive..." + local expected_part=1 + for part_file in "${zip_split_parts[@]}"; do + local expected_name + expected_name=$(printf "%s.z%02d" "$base_name" "$expected_part") + if [ "$(basename "$part_file")" != "$expected_name" ]; then + colorized_echo red "Missing split part $expected_name. Cannot restore split backup." + rm -rf "$temp_restore_dir" + exit 1 + fi + expected_part=$((expected_part + 1)) + done + + local concatenated_file="$temp_restore_dir/${base_name}_combined.zip" + if command -v zip >/dev/null 2>&1 && zip -s 0 "$selected_file" --out "$concatenated_file" >>"$log_file" 2>&1; then + archive_to_extract="$concatenated_file" + colorized_echo green "āœ“ Rebuilt split zip archive with zip utility" + else + if command -v zip >/dev/null 2>&1; then + colorized_echo yellow "zip rebuild failed. Falling back to direct concatenation..." + else + colorized_echo yellow "zip utility not found. Falling back to direct concatenation..." + fi + >"$concatenated_file" + local part_count=0 + for part_file in "${zip_split_parts[@]}"; do + if ! cat "$part_file" >>"$concatenated_file"; then + colorized_echo red "Failed to read split part: $(basename "$part_file")" + rm -rf "$temp_restore_dir" + exit 1 + fi + part_count=$((part_count + 1)) + done + if ! cat "$selected_file" >>"$concatenated_file"; then + colorized_echo red "Failed to read main zip file: $selected_filename" + rm -rf "$temp_restore_dir" + exit 1 + fi + archive_to_extract="$concatenated_file" + colorized_echo green "āœ“ Combined $((part_count + 1)) split part(s)" + fi + fi + else + archive_format="tar" + fi + + colorized_echo blue "Extracting backup..." + if [ "$archive_format" = "zip" ]; then + if ! command -v unzip >/dev/null 2>&1; then + detect_os + install_package unzip + fi + if ! unzip -tq "$archive_to_extract" >/dev/null 2>>"$log_file"; then + colorized_echo red "ERROR: The backup file is not a valid zip archive." + echo "File is not a valid zip archive: $archive_to_extract" >>"$log_file" + rm -rf "$temp_restore_dir" + exit 1 + fi + if ! unzip -oq "$archive_to_extract" -d "$temp_restore_dir" 2>>"$log_file"; then + colorized_echo red "Failed to extract backup file." + echo "Failed to extract $archive_to_extract" >>"$log_file" + rm -rf "$temp_restore_dir" + exit 1 + fi + else + if ! gzip -t "$archive_to_extract" 2>/dev/null; then + colorized_echo red "ERROR: The backup file is not a valid gzip archive." + echo "File is not a valid gzip archive: $archive_to_extract" >>"$log_file" + rm -rf "$temp_restore_dir" + exit 1 + fi + if ! tar -xzf "$archive_to_extract" -C "$temp_restore_dir" 2>>"$log_file"; then + colorized_echo red "Failed to extract backup file." + echo "Failed to extract $archive_to_extract" >>"$log_file" + rm -rf "$temp_restore_dir" + exit 1 + fi + fi + colorized_echo green "āœ“ Archive extracted successfully" + + # Load environment variables from extracted .env + colorized_echo blue "Loading configuration from backup..." + local extracted_env="$temp_restore_dir/.env" + if [ ! -f "$extracted_env" ]; then + colorized_echo red "Environment file not found in backup." + rm -rf "$temp_restore_dir" + exit 1 + fi + + local db_type="" + local sqlite_file="" + local db_host="" + local db_port="" + local db_user="" + local db_password="" + local db_name="" + local container_name="" + + # Load variables from extracted .env + # Check if file is readable + if [ ! -r "$extracted_env" ]; then + colorized_echo red "ERROR: .env file is not readable" + rm -rf "$temp_restore_dir" + exit 1 + fi + + # Check for binary content or null bytes (warning only, not fatal) + if grep -q $'\x00' "$extracted_env" 2>/dev/null; then + colorized_echo yellow "WARNING: .env file contains null bytes, cleaning..." + fi + + local env_vars_loaded=0 + + # Check if file has null bytes - if not, use it directly + local env_file_to_use="$extracted_env" + if grep -q $'\x00' "$extracted_env" 2>/dev/null; then + # File has null bytes, create cleaned version + local cleaned_env="/tmp/pasarguard_env_cleaned_$$" + set +e + tr -d '\000' < "$extracted_env" > "$cleaned_env" 2>/dev/null + local tr_result=$? + set -e + if [ $tr_result -eq 0 ] && [ -s "$cleaned_env" ]; then + env_file_to_use="$cleaned_env" + else + rm -f "$cleaned_env" + fi + fi + + # Use the EXACT same pattern as backup_command function + # This ensures compatibility and works in the current shell (no subshell) + colorized_echo blue "Loading environment variables..." + if [ -f "$env_file_to_use" ]; then + # Temporarily disable exit on error for the loop to handle failures gracefully + set +e + while IFS='=' read -r key value || [ -n "$key" ]; do + if [[ -z "$key" || "$key" =~ ^# ]]; then + continue + fi + # Trim whitespace from key and value + key=$(echo "$key" | xargs 2>/dev/null || echo "$key" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') + value=$(echo "$value" | xargs 2>/dev/null || echo "$value" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') + # Remove surrounding quotes from value if present + value=$(echo "$value" | sed -E 's/^["'\''](.*)["'\'']$/\1/' 2>/dev/null || echo "$value") + if [[ "$key" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]]; then + export "$key"="$value" 2>/dev/null || true + env_vars_loaded=$((env_vars_loaded + 1)) + else + echo "Skipping invalid line in .env: $key=$value" >&2 + fi + done <"$env_file_to_use" + set -e # Re-enable exit on error + else + colorized_echo red "Environment file (.env) not found in backup." + rm -rf "$temp_restore_dir" + exit 1 + fi + + # Clean up temporary cleaned file if we created one + if [ -n "${cleaned_env:-}" ] && [ -f "$cleaned_env" ]; then + rm -f "$cleaned_env" + fi + + colorized_echo green "āœ“ Loaded $env_vars_loaded environment variables" + + if [ -z "$SQLALCHEMY_DATABASE_URL" ]; then + colorized_echo red "SQLALCHEMY_DATABASE_URL not found in backup .env file" + colorized_echo yellow "Available environment variables:" + grep -v '^#' "$extracted_env" | grep '=' | cut -d'=' -f1 | head -10 + rm -rf "$temp_restore_dir" + exit 1 + fi + + colorized_echo green "āœ“ Found SQLALCHEMY_DATABASE_URL: $(redact_database_url "$SQLALCHEMY_DATABASE_URL")" + + # Parse database configuration (similar to backup function) + colorized_echo blue "Detecting database type..." + if [[ "$SQLALCHEMY_DATABASE_URL" =~ ^sqlite ]]; then + db_type="sqlite" + colorized_echo green "āœ“ Detected SQLite database" + local sqlite_url_part="${SQLALCHEMY_DATABASE_URL#*://}" + sqlite_url_part="${sqlite_url_part%%\?*}" + sqlite_url_part="${sqlite_url_part%%#*}" + + if [[ "$sqlite_url_part" =~ ^//(.*)$ ]]; then + sqlite_file="/${BASH_REMATCH[1]}" + elif [[ "$sqlite_url_part" =~ ^/(.*)$ ]]; then + sqlite_file="/${BASH_REMATCH[1]}" + else + sqlite_file="$sqlite_url_part" + fi + colorized_echo blue "Database file: $sqlite_file" + elif [[ "$SQLALCHEMY_DATABASE_URL" =~ ^(mysql|mariadb|postgresql)[^:]*:// ]]; then + if [[ "$SQLALCHEMY_DATABASE_URL" =~ ^mariadb[^:]*:// ]]; then + db_type="mariadb" + colorized_echo green "āœ“ Detected MariaDB database" + elif [[ "$SQLALCHEMY_DATABASE_URL" =~ ^mysql[^:]*:// ]]; then + db_type="mysql" + colorized_echo green "āœ“ Detected MySQL database" + elif [[ "$SQLALCHEMY_DATABASE_URL" =~ ^postgresql[^:]*:// ]]; then + # Check if it's timescaledb - use set +e to prevent failure on file not found + set +e + if grep -q "image: timescale/timescaledb" "$temp_restore_dir/docker-compose.yml" 2>/dev/null; then + db_type="timescaledb" + colorized_echo green "āœ“ Detected TimescaleDB database" + else + db_type="postgresql" + colorized_echo green "āœ“ Detected PostgreSQL database" + fi + set -e + fi + + local url_part="${SQLALCHEMY_DATABASE_URL#*://}" + url_part="${url_part%%\?*}" + url_part="${url_part%%#*}" + + if [[ "$url_part" =~ ^([^@]+)@(.+)$ ]]; then + local auth_part="${BASH_REMATCH[1]}" + url_part="${BASH_REMATCH[2]}" + + if [[ "$auth_part" =~ ^([^:]+):(.+)$ ]]; then + db_user="${BASH_REMATCH[1]}" + db_password="${BASH_REMATCH[2]}" + else + db_user="$auth_part" + fi + fi + + if [[ "$url_part" =~ ^([^:/]+)(:([0-9]+))?/(.+)$ ]]; then + db_host="${BASH_REMATCH[1]}" + db_port="${BASH_REMATCH[3]:-}" + db_name="${BASH_REMATCH[4]}" + db_name="${db_name%%\?*}" + db_name="${db_name%%#*}" + + if [ -z "$db_port" ]; then + if [[ "$db_type" =~ ^(mysql|mariadb)$ ]]; then + db_port="3306" + elif [[ "$db_type" =~ ^(postgresql|timescaledb)$ ]]; then + db_port="5432" + fi + fi + fi + + # Find container name for local databases + if [[ "$db_host" == "127.0.0.1" || "$db_host" == "localhost" || "$db_host" == "::1" ]]; then + set +e + container_name=$(find_container "$db_type") + set -e + fi + fi + + if [ -z "$db_type" ]; then + colorized_echo red "Could not determine database type from backup." + colorized_echo yellow "SQLALCHEMY_DATABASE_URL: ${SQLALCHEMY_DATABASE_URL:-not set}" + rm -rf "$temp_restore_dir" + exit 1 + fi + + colorized_echo green "āœ“ Database configuration detected: $db_type" + + # Confirm restore + colorized_echo red "āš ļø DANGER: This will PERMANENTLY overwrite your current $db_type database!" + colorized_echo yellow "WARNING: This will overwrite your current $db_type database!" + colorized_echo blue "Database type: $db_type" + if [ -n "$db_name" ]; then + colorized_echo blue "Database name: $db_name" + fi + if [ -n "$container_name" ]; then + colorized_echo blue "Container: $container_name" + fi + + while true; do + printf "Do you want to proceed with the restore? (yes/no): " + read -r confirm + if [[ "$confirm" =~ ^[Yy](es)?$ ]]; then + break + elif [[ "$confirm" =~ ^[Nn](o)?$ ]]; then + colorized_echo yellow "Restore cancelled." + rm -rf "$temp_restore_dir" + exit 0 + else + colorized_echo red "Please answer yes or no." + fi + done + + # Stop pasarguard services before restore for clean state + colorized_echo blue "Stopping pasarguard services for clean restore..." + if [[ "$db_type" == "sqlite" ]]; then + # For SQLite, stop all services since we need to restore files + down_pasarguard + else + # For containerized databases, stop only application services + # Keep database containers running for restore via docker exec + stop_pasarguard_app_services + fi + + # Perform restore + colorized_echo red "āš ļø DANGER: Starting database restore - this will overwrite existing data!" + colorized_echo blue "Starting database restore..." + + case $db_type in + sqlite) + if [ ! -f "$temp_restore_dir/db_backup.sqlite" ]; then + colorized_echo red "SQLite backup file not found in backup archive." + rm -rf "$temp_restore_dir" + exit 1 + fi + + if [ -f "$sqlite_file" ]; then + cp "$sqlite_file" "${sqlite_file}.backup.$(date +%Y%m%d%H%M%S)" 2>>"$log_file" + fi + + if cp "$temp_restore_dir/db_backup.sqlite" "$sqlite_file" 2>>"$log_file"; then + colorized_echo green "SQLite database restored successfully." + else + colorized_echo red "Failed to restore SQLite database." + echo "SQLite restore failed" >>"$log_file" + rm -rf "$temp_restore_dir" + exit 1 + fi + ;; + + mariadb|mysql) + if [ ! -f "$temp_restore_dir/db_backup.sql" ]; then + colorized_echo red "Database backup file not found in backup archive." + rm -rf "$temp_restore_dir" + exit 1 + fi + + if [[ "$db_host" == "127.0.0.1" || "$db_host" == "localhost" || "$db_host" == "::1" ]]; then + if [ -z "$container_name" ]; then + colorized_echo red "Error: MySQL/MariaDB container not found. Is the container running?" + echo "MySQL/MariaDB container not found. Container name: ${container_name:-empty}" >>"$log_file" + rm -rf "$temp_restore_dir" + exit 1 + else + local verified_container=$(verify_and_start_container "$container_name" "$db_type") + if [ -z "$verified_container" ]; then + colorized_echo red "Failed to start database container. Please start it manually." + rm -rf "$temp_restore_dir" + exit 1 + fi + container_name="$verified_container" + + # Check if this is actually a MariaDB container + local is_mariadb=false + local mysql_cmd="mysql" + local db_type_name="MySQL" + if docker exec "$container_name" mariadb --version >/dev/null 2>&1; then + is_mariadb=true + mysql_cmd="mariadb" + db_type_name="MariaDB" + fi + + colorized_echo blue "Restoring $db_type_name database from container: $container_name" + + local restore_success=false + local backup_restore_user="${db_user:-${DB_USER:-}}" + local backup_restore_password="${db_password:-${DB_PASSWORD:-}}" + local app_db_target="${db_name:-${current_db_name:-}}" + + # Try root password from backup .env first + if [ -n "${MYSQL_ROOT_PASSWORD:-}" ]; then + colorized_echo blue "Trying root user from backup .env..." + if docker exec -i "$container_name" "$mysql_cmd" -u root -p"$MYSQL_ROOT_PASSWORD" < "$temp_restore_dir/db_backup.sql" 2>>"$log_file"; then + restore_success=true + colorized_echo green "$db_type_name database restored successfully." + else + colorized_echo yellow "Root restore failed with backup .env credentials, trying fallback..." + echo "$db_type_name restore failed with backup MYSQL_ROOT_PASSWORD" >>"$log_file" + fi + fi + + # If root password changed after backup, try current installation value + if [ "$restore_success" = false ] && [ -n "$current_mysql_root_password" ] && [ "$current_mysql_root_password" != "${MYSQL_ROOT_PASSWORD:-}" ]; then + colorized_echo blue "Trying root user from current installation .env..." + if docker exec -i "$container_name" "$mysql_cmd" -u root -p"$current_mysql_root_password" < "$temp_restore_dir/db_backup.sql" 2>>"$log_file"; then + restore_success=true + colorized_echo green "$db_type_name database restored successfully." + else + colorized_echo yellow "Root restore failed with current .env credentials, trying app user fallback..." + echo "$db_type_name restore failed with current MYSQL_ROOT_PASSWORD" >>"$log_file" + fi + fi + + # Try app user from backup SQL URL/.env + if [ "$restore_success" = false ] && [ -n "$backup_restore_user" ] && [ -n "$backup_restore_password" ]; then + colorized_echo blue "Trying app user '$backup_restore_user' from backup credentials..." + if [ -n "$app_db_target" ]; then + if docker exec -i "$container_name" "$mysql_cmd" -u "$backup_restore_user" -p"$backup_restore_password" "$app_db_target" < "$temp_restore_dir/db_backup.sql" 2>>"$log_file"; then + restore_success=true + colorized_echo green "$db_type_name database restored successfully." + fi + fi + if [ "$restore_success" = false ] && docker exec -i "$container_name" "$mysql_cmd" -u "$backup_restore_user" -p"$backup_restore_password" < "$temp_restore_dir/db_backup.sql" 2>>"$log_file"; then + restore_success=true + colorized_echo green "$db_type_name database restored successfully." + elif [ "$restore_success" = false ]; then + colorized_echo yellow "App user restore failed with backup credentials, trying current installation credentials..." + echo "$db_type_name restore failed with backup app credentials" >>"$log_file" + fi + fi + + # Final fallback: current installation app credentials + if [ "$restore_success" = false ] && [ -n "$current_db_user" ] && [ -n "$current_db_password" ] && { [ "$current_db_user" != "$backup_restore_user" ] || [ "$current_db_password" != "$backup_restore_password" ]; }; then + colorized_echo blue "Trying app user '$current_db_user' from current installation .env..." + if [ -n "$app_db_target" ]; then + if docker exec -i "$container_name" "$mysql_cmd" -u "$current_db_user" -p"$current_db_password" "$app_db_target" < "$temp_restore_dir/db_backup.sql" 2>>"$log_file"; then + restore_success=true + colorized_echo green "$db_type_name database restored successfully." + fi + fi + if [ "$restore_success" = false ] && docker exec -i "$container_name" "$mysql_cmd" -u "$current_db_user" -p"$current_db_password" < "$temp_restore_dir/db_backup.sql" 2>>"$log_file"; then + restore_success=true + colorized_echo green "$db_type_name database restored successfully." + elif [ "$restore_success" = false ]; then + echo "$db_type_name restore failed with current app credentials" >>"$log_file" + fi + fi + + if [ "$restore_success" = false ]; then + colorized_echo red "Failed to restore $db_type_name database with all available credentials." + colorized_echo yellow "Check log file for details: $log_file" + rm -rf "$temp_restore_dir" + exit 1 + fi + fi + else + colorized_echo red "Remote $db_type restore not supported yet." + rm -rf "$temp_restore_dir" + exit 1 + fi + ;; + + postgresql|timescaledb) + if [ ! -f "$temp_restore_dir/db_backup.sql" ]; then + colorized_echo red "Database backup file not found in backup archive." + rm -rf "$temp_restore_dir" + exit 1 + fi + + # Verify backup file is not empty and is readable + if [ ! -s "$temp_restore_dir/db_backup.sql" ]; then + colorized_echo red "Database backup file is empty or unreadable." + rm -rf "$temp_restore_dir" + exit 1 + fi + + local backup_size=$(du -h "$temp_restore_dir/db_backup.sql" | cut -f1) + colorized_echo blue "Backup file size: $backup_size" + + if [[ "$db_host" == "127.0.0.1" || "$db_host" == "localhost" || "$db_host" == "::1" ]] && [ -n "$container_name" ]; then + local verified_container=$(verify_and_start_container "$container_name" "$db_type") + if [ -z "$verified_container" ]; then + colorized_echo red "Failed to start database container. Please start it manually." + rm -rf "$temp_restore_dir" + exit 1 + fi + container_name="$verified_container" + + colorized_echo blue "Restoring $db_type database from container: $container_name" + + # Prepare restore credentials, preferring the current installation values. + local restore_user="${current_db_user:-${db_user:-${DB_USER:-postgres}}}" + local restore_password="${current_db_password:-${db_password:-${DB_PASSWORD:-}}}" + local restore_db_name="${current_db_name:-${db_name:-${DB_NAME:-postgres}}}" + + if [ -z "$restore_password" ]; then + colorized_echo red "No database password found for restore." + rm -rf "$temp_restore_dir" + exit 1 + fi + + export PGPASSWORD="$restore_password" + local restore_success=false + + if [ "$db_type" = "timescaledb" ]; then + # TimescaleDB requires special restore procedure to handle version mismatches. + # A plain psql restore fails when the backup was taken with a different + # TimescaleDB version because DROP EXTENSION / CREATE EXTENSION cycles + # break when the shared library is already loaded with the new version. + # The fix: drop & recreate the database, then use the official + # timescaledb_pre_restore() / timescaledb_post_restore() wrapper. + # See: https://docs.timescale.com/self-hosted/latest/backup-and-restore/ + colorized_echo blue "Using TimescaleDB-safe restore procedure..." + + # Use target installation's identity when available, falling back to backup values. + # This ensures cross-server restores work correctly when the local DB user/name + # differs from the backup source. + local target_db_name="$restore_db_name" + local target_db_owner="${current_db_user:-$restore_user}" + + # Drop and recreate the target database for a clean slate + colorized_echo blue "Dropping and recreating database '$target_db_name'..." + docker exec "$container_name" psql -U postgres -d postgres \ + -v db_name="$target_db_name" \ + -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = :'db_name' AND pid <> pg_backend_pid();" \ + >>"$log_file" 2>&1 + docker exec "$container_name" psql -U postgres -d postgres \ + -v db_name="$target_db_name" \ + -c "DROP DATABASE IF EXISTS :\"db_name\";" >>"$log_file" 2>&1 + docker exec "$container_name" psql -U postgres -d postgres \ + -v db_name="$target_db_name" -v db_owner="$target_db_owner" \ + -c "CREATE DATABASE :\"db_name\" OWNER :\"db_owner\";" >>"$log_file" 2>&1 + + # Create the timescaledb extension in the fresh database + docker exec "$container_name" psql -U postgres --dbname="$target_db_name" \ + -c "CREATE EXTENSION IF NOT EXISTS timescaledb;" >>"$log_file" 2>&1 + + # Call pre_restore to put TimescaleDB into restore mode + colorized_echo blue "Calling timescaledb_pre_restore()..." + docker exec "$container_name" psql -U postgres --dbname="$target_db_name" \ + -c "SELECT timescaledb_pre_restore();" >>"$log_file" 2>&1 + + # Filter out extension DROP/CREATE statements from the dump. + # pg_dump --clean --if-exists generates DROP EXTENSION / CREATE EXTENSION + # lines that would undo the pre_restore() setup above. + colorized_echo blue "Preparing dump (filtering extension statements)..." + grep -v -E '^\s*(DROP|CREATE)\s+EXTENSION\s+(IF\s+(EXISTS|NOT\s+EXISTS)\s+)?timescaledb\b' \ + "$temp_restore_dir/db_backup.sql" > "$temp_restore_dir/db_backup_filtered.sql" 2>>"$log_file" + + # Restore the filtered dump with ON_ERROR_STOP so psql exits non-zero on SQL errors + colorized_echo blue "Restoring database dump..." + if docker exec -i "$container_name" psql -v ON_ERROR_STOP=1 -U "$restore_user" --dbname="$target_db_name" < "$temp_restore_dir/db_backup_filtered.sql" 2>>"$log_file"; then + restore_success=true + else + # Fallback: try with postgres superuser + colorized_echo yellow "Trying with postgres superuser..." + if docker exec -i "$container_name" psql -v ON_ERROR_STOP=1 -U postgres --dbname="$target_db_name" < "$temp_restore_dir/db_backup_filtered.sql" 2>>"$log_file"; then + restore_success=true + fi + fi + + # Clean up filtered dump + rm -f "$temp_restore_dir/db_backup_filtered.sql" + + # Call post_restore regardless of outcome to leave DB in a usable state + colorized_echo blue "Calling timescaledb_post_restore()..." + docker exec "$container_name" psql -U postgres --dbname="$target_db_name" \ + -c "SELECT timescaledb_post_restore();" >>"$log_file" 2>&1 + + if [ "$restore_success" = true ]; then + colorized_echo green "TimescaleDB database restored successfully." + fi + else + # Plain PostgreSQL restore with ON_ERROR_STOP so psql exits non-zero on SQL errors + colorized_echo blue "Attempting restore using app user '$restore_user' to database '$restore_db_name'..." + if docker exec -i "$container_name" psql -v ON_ERROR_STOP=1 -U "$restore_user" -d "$restore_db_name" < "$temp_restore_dir/db_backup.sql" 2>>"$log_file"; then + colorized_echo green "$db_type database restored successfully." + restore_success=true + else + # If that fails, try using postgres superuser + colorized_echo yellow "Trying with postgres superuser..." + if docker exec -i "$container_name" psql -v ON_ERROR_STOP=1 -U postgres -d "$db_name" < "$temp_restore_dir/db_backup.sql" 2>>"$log_file"; then + colorized_echo green "$db_type database restored successfully." + restore_success=true + else + # Try restoring to postgres database (for pg_dumpall backups) + if docker exec -i "$container_name" psql -v ON_ERROR_STOP=1 -U postgres -d postgres < "$temp_restore_dir/db_backup.sql" 2>>"$log_file"; then + colorized_echo green "$db_type database restored successfully." + restore_success=true + fi + fi + fi + fi + + unset PGPASSWORD + + if [ "$restore_success" = false ]; then + colorized_echo red "Failed to restore $db_type database." + colorized_echo yellow "Check log file for details: $log_file" + rm -rf "$temp_restore_dir" + exit 1 + fi + else + colorized_echo red "Remote $db_type restore not supported yet." + rm -rf "$temp_restore_dir" + exit 1 + fi + ;; + *) + colorized_echo red "Unsupported database type: $db_type" + rm -rf "$temp_restore_dir" + exit 1 + ;; + esac + + # Restore data directory if included in backup + colorized_echo blue "Restoring data directory..." + local extracted_data_dir="$temp_restore_dir/pasarguard_data" + if [ -d "$extracted_data_dir" ]; then + if ! command -v rsync >/dev/null 2>&1; then + detect_os + install_package rsync + fi + mkdir -p "$DATA_DIR" + if ! rsync -a "$extracted_data_dir/" "$DATA_DIR/" 2>>"$log_file"; then + colorized_echo red "Failed to restore data directory." + echo "Failed to restore data directory from $extracted_data_dir to $DATA_DIR" >>"$log_file" + rm -rf "$temp_restore_dir" + exit 1 + fi + colorized_echo green "Data directory restored to $DATA_DIR." + else + colorized_echo yellow "No pasarguard_data directory found in backup. Skipping data restore." + fi + + # Restore configuration files if needed + colorized_echo blue "Restoring configuration files..." + if [ -f "$temp_restore_dir/.env" ]; then + if [ -f "$APP_DIR/.env" ]; then + cp "$APP_DIR/.env" "$APP_DIR/.env.backup.$(date +%Y%m%d%H%M%S)" 2>>"$log_file" + fi + cp "$temp_restore_dir/.env" "$APP_DIR/.env" 2>>"$log_file" + colorized_echo green "Environment file restored." + local preserve_db_credentials=false + if [[ "$db_type" != "sqlite" ]]; then + if [ -n "$current_db_user" ] && [ -n "${DB_USER:-}" ] && [ "$current_db_user" != "$DB_USER" ]; then + preserve_db_credentials=true + elif [ -n "$current_db_name" ] && [ -n "${DB_NAME:-}" ] && [ "$current_db_name" != "$DB_NAME" ]; then + preserve_db_credentials=true + elif [ -n "$current_db_password" ] && [ -n "${DB_PASSWORD:-}" ] && [ "$current_db_password" != "$DB_PASSWORD" ]; then + preserve_db_credentials=true + fi + fi + if [ "$preserve_db_credentials" = true ]; then + colorized_echo yellow "Database credentials in backup differ from current installation; preserving current database credentials." + if [ -n "$current_mysql_root_password" ]; then + replace_or_append_env_var "MYSQL_ROOT_PASSWORD" "$current_mysql_root_password" true "$ENV_FILE" + fi + if [ -n "$current_db_user" ]; then + replace_or_append_env_var "DB_USER" "$current_db_user" false "$ENV_FILE" + fi + if [ -n "$current_db_name" ]; then + replace_or_append_env_var "DB_NAME" "$current_db_name" false "$ENV_FILE" + fi + if [ -n "$current_db_password" ]; then + replace_or_append_env_var "DB_PASSWORD" "$current_db_password" false "$ENV_FILE" + fi + if [ -n "$current_sqlalchemy_url" ]; then + replace_or_append_env_var "SQLALCHEMY_DATABASE_URL" "$current_sqlalchemy_url" true "$ENV_FILE" + fi + fi + fi + + if [ -f "$temp_restore_dir/docker-compose.yml" ]; then + if [ -f "$APP_DIR/docker-compose.yml" ]; then + cp "$APP_DIR/docker-compose.yml" "$APP_DIR/docker-compose.yml.backup.$(date +%Y%m%d%H%M%S)" 2>>"$log_file" + fi + cp "$temp_restore_dir/docker-compose.yml" "$APP_DIR/docker-compose.yml" 2>>"$log_file" + colorized_echo green "Docker Compose file restored." + fi + + # Clean up + rm -rf "$temp_restore_dir" + + # Restart pasarguard services + colorized_echo blue "Restarting pasarguard services..." + if [[ "$db_type" == "sqlite" ]]; then + # For SQLite, restart all services + up_pasarguard + else + # For containerized databases, restart only application services + start_pasarguard_app_services + fi + + colorized_echo green "Restore completed successfully!" + colorized_echo green "PasarGuard services have been restarted." +} diff --git a/lib/system.sh b/lib/system.sh new file mode 100644 index 0000000..125a3c0 --- /dev/null +++ b/lib/system.sh @@ -0,0 +1,226 @@ +#!/usr/bin/env bash + +check_running_as_root() { + if [ "$(id -u)" != "0" ]; then + die "This command must be run as root." + fi +} + +detect_os() { + if [ -f /etc/lsb-release ] && command -v lsb_release >/dev/null 2>&1; then + OS=$(lsb_release -si) + elif [ -f /etc/os-release ]; then + OS=$(awk -F= '/^NAME/{print $2}' /etc/os-release | tr -d '"') + elif [ -f /etc/redhat-release ]; then + OS=$(awk '{print $1}' /etc/redhat-release) + elif [ -f /etc/arch-release ]; then + OS="Arch Linux" + else + die "Unsupported operating system" + fi +} + +detect_and_update_package_manager() { + if [ -z "${OS:-}" ]; then + detect_os + fi + + colorized_echo blue "Updating package manager" + + if [[ "$OS" == "Ubuntu"* ]] || [[ "$OS" == "Debian"* ]]; then + PKG_MANAGER="apt-get" + $PKG_MANAGER update -qq >/dev/null 2>&1 + elif [[ "$OS" == "CentOS"* ]] || [[ "$OS" == "AlmaLinux"* ]]; then + PKG_MANAGER="yum" + $PKG_MANAGER update -y -q >/dev/null 2>&1 + $PKG_MANAGER install -y -q epel-release >/dev/null 2>&1 + elif [[ "$OS" == "Fedora"* ]]; then + PKG_MANAGER="dnf" + $PKG_MANAGER update -q -y >/dev/null 2>&1 + elif [[ "$OS" == "Arch Linux" ]] || [[ "$OS" == "Arch"* ]]; then + PKG_MANAGER="pacman" + $PKG_MANAGER -Sy --noconfirm --quiet >/dev/null 2>&1 + elif [[ "$OS" == "openSUSE"* ]]; then + PKG_MANAGER="zypper" + $PKG_MANAGER refresh --quiet >/dev/null 2>&1 + else + die "Unsupported operating system" + fi +} + +install_package() { + local package="$1" + + if [ -z "${OS:-}" ]; then + detect_os + fi + + if [ -z "${PKG_MANAGER:-}" ]; then + detect_and_update_package_manager + fi + + colorized_echo blue "Installing $package" + if [[ "$OS" == "Ubuntu"* ]] || [[ "$OS" == "Debian"* ]]; then + $PKG_MANAGER -y -qq install "$package" >/dev/null 2>&1 + elif [[ "$OS" == "CentOS"* ]] || [[ "$OS" == "AlmaLinux"* ]]; then + $PKG_MANAGER install -y -q "$package" >/dev/null 2>&1 + elif [[ "$OS" == "Fedora"* ]]; then + $PKG_MANAGER install -y -q "$package" >/dev/null 2>&1 + elif [[ "$OS" == "Arch Linux" ]] || [[ "$OS" == "Arch"* ]]; then + $PKG_MANAGER -S --noconfirm --quiet "$package" >/dev/null 2>&1 + elif [[ "$OS" == "openSUSE"* ]]; then + $PKG_MANAGER --quiet install -y "$package" >/dev/null 2>&1 + else + die "Unsupported operating system" + fi +} + +check_editor() { + if [ -z "${EDITOR:-}" ]; then + if command -v nano >/dev/null 2>&1; then + EDITOR="nano" + elif command -v vi >/dev/null 2>&1; then + EDITOR="vi" + else + detect_os + install_package nano + EDITOR="nano" + fi + fi +} + +identify_the_operating_system_and_architecture() { + if [[ "$(uname)" != "Linux" ]]; then + die "error: This operating system is not supported." + fi + + case "$(uname -m)" in + i386 | i686) + ARCH='32' + ;; + amd64 | x86_64) + ARCH='64' + ;; + armv5tel) + ARCH='arm32-v5' + ;; + armv6l) + ARCH='arm32-v6' + grep Features /proc/cpuinfo | grep -qw 'vfp' || ARCH='arm32-v5' + ;; + armv7 | armv7l) + ARCH='arm32-v7a' + grep Features /proc/cpuinfo | grep -qw 'vfp' || ARCH='arm32-v5' + ;; + armv8 | aarch64) + ARCH='arm64-v8a' + ;; + mips) + ARCH='mips32' + ;; + mipsle) + ARCH='mips32le' + ;; + mips64) + ARCH='mips64' + lscpu | grep -q "Little Endian" && ARCH='mips64le' + ;; + mips64le) + ARCH='mips64le' + ;; + ppc64) + ARCH='ppc64' + ;; + ppc64le) + ARCH='ppc64le' + ;; + riscv64) + ARCH='riscv64' + ;; + s390x) + ARCH='s390x' + ;; + *) + die "error: The architecture is not supported." + ;; + esac +} + +install_yq() { + local base_url="https://github.com/mikefarah/yq/releases/latest/download" + local yq_binary="" + local yq_url="" + local checksum_url="${base_url}/checksums" + local binary_tmp="" + local checksum_tmp="" + local expected_checksum="" + local actual_checksum="" + + if command -v yq >/dev/null 2>&1; then + colorized_echo green "yq is already installed." + return + fi + + identify_the_operating_system_and_architecture + + case "$ARCH" in + 64 | x86_64) + yq_binary="yq_linux_amd64" + ;; + arm32-v7a | arm32-v6 | arm32-v5 | armv7l) + yq_binary="yq_linux_arm" + ;; + arm64-v8a | aarch64) + yq_binary="yq_linux_arm64" + ;; + 32 | i386 | i686) + yq_binary="yq_linux_386" + ;; + *) + die "Unsupported architecture: $ARCH" + ;; + esac + + yq_url="${base_url}/${yq_binary}" + colorized_echo blue "Downloading yq from ${yq_url}..." + + if ! command -v curl >/dev/null 2>&1 && ! command -v wget >/dev/null 2>&1; then + colorized_echo yellow "Neither curl nor wget is installed. Attempting to install curl." + install_package curl || die "Failed to install curl. Please install curl or wget manually." + fi + + binary_tmp=$(create_temp_file "yq" ".bin") + checksum_tmp=$(create_temp_file "yq" ".checksums") + + if command -v curl >/dev/null 2>&1; then + curl -fsSL "$yq_url" -o "$binary_tmp" || die "Failed to download yq using curl. Please check your internet connection." + curl -fsSL "$checksum_url" -o "$checksum_tmp" || die "Failed to download yq checksums using curl." + elif command -v wget >/dev/null 2>&1; then + wget -q -O "$binary_tmp" "$yq_url" || die "Failed to download yq using wget. Please check your internet connection." + wget -q -O "$checksum_tmp" "$checksum_url" || die "Failed to download yq checksums using wget." + fi + + expected_checksum=$(awk -v name="$yq_binary" '$2 == name { print $1; exit }' "$checksum_tmp") + [ -n "$expected_checksum" ] || die "Failed to resolve published checksum for $yq_binary." + + if command -v sha256sum >/dev/null 2>&1; then + actual_checksum=$(sha256sum "$binary_tmp" | awk '{print $1}') + elif command -v shasum >/dev/null 2>&1; then + actual_checksum=$(shasum -a 256 "$binary_tmp" | awk '{print $1}') + elif command -v openssl >/dev/null 2>&1; then + actual_checksum=$(openssl dgst -sha256 "$binary_tmp" | awk '{print $NF}') + else + die "No SHA-256 tool available to verify yq download." + fi + + [ "$actual_checksum" = "$expected_checksum" ] || die "Downloaded yq checksum mismatch." + + install -m 755 "$binary_tmp" /usr/local/bin/yq + colorized_echo green "yq installed successfully!" + + if ! echo "$PATH" | grep -q "/usr/local/bin"; then + export PATH="/usr/local/bin:$PATH" + fi + + rm -f "$binary_tmp" "$checksum_tmp" +} diff --git a/pasarguard.sh b/pasarguard.sh index 47a2e66..0da005e 100755 --- a/pasarguard.sh +++ b/pasarguard.sh @@ -1,6 +1,75 @@ #!/usr/bin/env bash set -e +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +SHARED_LIB_DIR="${SCRIPT_DIR}/lib" +REQUIRED_SHARED_LIBS="common.sh system.sh docker.sh github.sh env.sh pasarguard-backup.sh pasarguard-restore.sh" +if [ ! -f "$SHARED_LIB_DIR/common.sh" ]; then + SHARED_LIB_DIR="/usr/local/lib/pasarguard-scripts/lib" +fi + +bootstrap_pasarguard_shared_libs() { + local fetch_repo="PasarGuard/scripts" + local bootstrap_dir="/usr/local/lib/pasarguard-scripts/lib" + local tmp_dir="" + local shared_lib="" + + tmp_dir=$(mktemp -d) || return 1 + mkdir -p "$bootstrap_dir" || { + rm -rf "$tmp_dir" + return 1 + } + + for shared_lib in $REQUIRED_SHARED_LIBS; do + if ! curl -fsSL "https://github.com/${fetch_repo}/raw/main/lib/${shared_lib}" -o "$tmp_dir/$shared_lib"; then + rm -rf "$tmp_dir" + return 1 + fi + if ! install -m 644 "$tmp_dir/$shared_lib" "$bootstrap_dir/$shared_lib"; then + rm -rf "$tmp_dir" + return 1 + fi + done + + rm -rf "$tmp_dir" + SHARED_LIB_DIR="$bootstrap_dir" + return 0 +} + +missing_shared_lib=false +for shared_lib in $REQUIRED_SHARED_LIBS; do + if [ ! -f "$SHARED_LIB_DIR/$shared_lib" ]; then + missing_shared_lib=true + break + fi +done + +if [ "$missing_shared_lib" = true ]; then + bootstrap_pasarguard_shared_libs +fi + +for shared_lib in $REQUIRED_SHARED_LIBS; do + if [ ! -f "$SHARED_LIB_DIR/$shared_lib" ]; then + printf 'Missing shared library: %s\n' "$SHARED_LIB_DIR/$shared_lib" >&2 + exit 1 + fi +done + +# shellcheck source=lib/common.sh +source "$SHARED_LIB_DIR/common.sh" +# shellcheck source=lib/system.sh +source "$SHARED_LIB_DIR/system.sh" +# shellcheck source=lib/docker.sh +source "$SHARED_LIB_DIR/docker.sh" +# shellcheck source=lib/github.sh +source "$SHARED_LIB_DIR/github.sh" +# shellcheck source=lib/env.sh +source "$SHARED_LIB_DIR/env.sh" +# shellcheck source=lib/pasarguard-backup.sh +source "$SHARED_LIB_DIR/pasarguard-backup.sh" +# shellcheck source=lib/pasarguard-restore.sh +source "$SHARED_LIB_DIR/pasarguard-restore.sh" + # Handle @ symbol if used in installation (skip it) if [ "$1" == "@" ]; then shift @@ -17,101 +86,6 @@ COMPOSE_FILE="$APP_DIR/docker-compose.yml" ENV_FILE="$APP_DIR/.env" LAST_XRAY_CORES=10 -replace_or_append_env_var() { - local key="$1" - local value="$2" - local quote_value="${3:-false}" - local target_file="${4:-$ENV_FILE}" - local formatted_value="$value" - - if [ "$quote_value" = "true" ]; then - local sanitized_value="${value//\"/\\\"}" - formatted_value="\"$sanitized_value\"" - fi - - local escaped_value - escaped_value=$(printf '%s' "$formatted_value" | sed -e 's/[&|\\]/\\&/g') - - if grep -q "^$key=" "$target_file"; then - sed -i "s|^$key=.*|$key=$escaped_value|" "$target_file" - else - printf '%s=%s\n' "$key" "$formatted_value" >>"$target_file" - fi -} - -set_or_uncomment_env_var() { - local key="$1" - local value="$2" - local quote_value="${3:-false}" - local target_file="${4:-$ENV_FILE}" - local formatted_value="$value" - local tmp_file="" - - if [ "$quote_value" = "true" ]; then - local sanitized_value="${value//\"/\\\"}" - formatted_value="\"$sanitized_value\"" - fi - - [ -f "$target_file" ] || touch "$target_file" - tmp_file=$(mktemp) - - awk -v env_key="$key" -v env_line="${key} = ${formatted_value}" ' - BEGIN { replaced = 0 } - { - if ($0 ~ "^[[:space:]]*#?[[:space:]]*" env_key "[[:space:]]*=") { - if (replaced == 0) { - print env_line - replaced = 1 - } - next - } - print - } - END { - if (replaced == 0) { - print env_line - } - } - ' "$target_file" >"$tmp_file" - - mv "$tmp_file" "$target_file" -} - -comment_out_env_var() { - local key="$1" - local target_file="${2:-$ENV_FILE}" - local tmp_file="" - - [ -f "$target_file" ] || return 0 - tmp_file=$(mktemp) - - awk -v env_key="$key" ' - BEGIN { done = 0 } - { - if ($0 ~ "^[[:space:]]*#?[[:space:]]*" env_key "[[:space:]]*=") { - if (done == 0) { - line = $0 - sub("^[[:space:]]*#?[[:space:]]*" env_key "[[:space:]]*=[[:space:]]*", "", line) - print "# " env_key " = " line - done = 1 - } - next - } - print - } - ' "$target_file" >"$tmp_file" - - mv "$tmp_file" "$target_file" -} - -delete_env_var() { - local key="$1" - local target_file="${2:-$ENV_FILE}" - - [ -f "$target_file" ] || return 0 - sed -i "/^[[:space:]]*${key}[[:space:]]*=/d" "$target_file" -} - is_valid_proxy_url() { local proxy_url="$1" [[ -z "$proxy_url" ]] && return 1 @@ -137,122 +111,6 @@ get_backup_proxy_url() { return 0 } -colorized_echo() { - local color=$1 - local text=$2 - - case $color in - "red") - printf "\e[91m${text}\e[0m\n" - ;; - "green") - printf "\e[92m${text}\e[0m\n" - ;; - "yellow") - printf "\e[93m${text}\e[0m\n" - ;; - "blue") - printf "\e[94m${text}\e[0m\n" - ;; - "magenta") - printf "\e[95m${text}\e[0m\n" - ;; - "cyan") - printf "\e[96m${text}\e[0m\n" - ;; - *) - echo "${text}" - ;; - esac -} - -check_running_as_root() { - if [ "$(id -u)" != "0" ]; then - colorized_echo red "This command must be run as root." - exit 1 - fi -} - -detect_os() { - # Detect the operating system - if [ -f /etc/lsb-release ]; then - OS=$(lsb_release -si) - elif [ -f /etc/os-release ]; then - OS=$(awk -F= '/^NAME/{print $2}' /etc/os-release | tr -d '"') - elif [ -f /etc/redhat-release ]; then - OS=$(cat /etc/redhat-release | awk '{print $1}') - elif [ -f /etc/arch-release ]; then - OS="Arch Linux" - else - colorized_echo red "Unsupported operating system" - exit 1 - fi -} - -detect_and_update_package_manager() { - colorized_echo blue "Updating package manager" - if [[ "$OS" == "Ubuntu"* ]] || [[ "$OS" == "Debian"* ]]; then - PKG_MANAGER="apt-get" - $PKG_MANAGER update - elif [[ "$OS" == "CentOS"* ]] || [[ "$OS" == "AlmaLinux"* ]]; then - PKG_MANAGER="yum" - $PKG_MANAGER update -y - $PKG_MANAGER install -y epel-release - elif [ "$OS" == "Fedora"* ]; then - PKG_MANAGER="dnf" - $PKG_MANAGER update - elif [ "$OS" == "Arch Linux" ]; then - PKG_MANAGER="pacman" - $PKG_MANAGER -Sy - elif [[ "$OS" == "openSUSE"* ]]; then - PKG_MANAGER="zypper" - $PKG_MANAGER refresh - else - colorized_echo red "Unsupported operating system" - exit 1 - fi -} - -install_package() { - if [ -z $PKG_MANAGER ]; then - detect_and_update_package_manager - fi - - PACKAGE=$1 - colorized_echo blue "Installing $PACKAGE" - if [[ "$OS" == "Ubuntu"* ]] || [[ "$OS" == "Debian"* ]]; then - $PKG_MANAGER -y install "$PACKAGE" - elif [[ "$OS" == "CentOS"* ]] || [[ "$OS" == "AlmaLinux"* ]]; then - $PKG_MANAGER install -y "$PACKAGE" - elif [ "$OS" == "Fedora"* ]; then - $PKG_MANAGER install -y "$PACKAGE" - elif [ "$OS" == "Arch Linux" ]; then - $PKG_MANAGER -S --noconfirm "$PACKAGE" - else - colorized_echo red "Unsupported operating system" - exit 1 - fi -} - -install_docker() { - # Install Docker and Docker Compose using the official installation script - colorized_echo blue "Installing Docker" - curl -fsSL https://get.docker.com | sh - colorized_echo green "Docker installed successfully" -} - -detect_compose() { - # Check if docker compose command exists - if docker compose version >/dev/null 2>&1; then - COMPOSE='docker compose' - elif docker-compose version >/dev/null 2>&1; then - COMPOSE='docker-compose' - else - colorized_echo red "docker compose not found" - exit 1 - fi -} - is_domain() { [[ "$1" =~ ^([A-Za-z0-9](-*[A-Za-z0-9])*\.)+(xn--[a-z0-9]{2,}|[A-Za-z]{2,})$ ]] && return 0 || return 1 } @@ -879,2420 +737,165 @@ detect_pasarguard_backend_service() { next } END { - if (service != "" && is_backend) { - print service - } - } - ' | head -n 1) - - if [ -n "$service_name" ]; then - echo "$service_name" - return 0 - fi - - service_name=$(list_pasarguard_app_services | head -n 1) - if [ -n "$service_name" ]; then - echo "$service_name" - return 0 - fi - - return 1 -} - -stop_pasarguard_app_services() { - local services - services=$(list_pasarguard_app_services | xargs) - [ -z "$services" ] && services="pasarguard" - $COMPOSE -f "$COMPOSE_FILE" -p "$APP_NAME" stop $services 2>/dev/null || true -} - -start_pasarguard_app_services() { - local services - services=$(list_pasarguard_app_services | xargs) - [ -z "$services" ] && services="pasarguard" - $COMPOSE -f "$COMPOSE_FILE" -p "$APP_NAME" start $services 2>/dev/null || true -} - -find_container() { - local db_type=$1 - local container_name="" - detect_compose - - case $db_type in - mariadb) - container_name=$($COMPOSE -f "$COMPOSE_FILE" -p "$APP_NAME" ps -q mariadb 2>/dev/null || true) - [ -z "$container_name" ] && container_name=$($COMPOSE -f "$COMPOSE_FILE" -p "$APP_NAME" ps --format json mariadb 2>/dev/null | jq -r '.Name' 2>/dev/null | head -n 1 || true) - [ -z "$container_name" ] && container_name=$(docker ps --filter "name=${APP_NAME}" --filter "name=mariadb" --format '{{.ID}}' 2>/dev/null | head -n 1 || true) - [ -z "$container_name" ] && container_name="mariadb" - ;; - mysql) - container_name=$($COMPOSE -f "$COMPOSE_FILE" -p "$APP_NAME" ps -q mysql 2>/dev/null || true) - [ -z "$container_name" ] && container_name=$($COMPOSE -f "$COMPOSE_FILE" -p "$APP_NAME" ps -q mariadb 2>/dev/null || true) - [ -z "$container_name" ] && container_name=$($COMPOSE -f "$COMPOSE_FILE" -p "$APP_NAME" ps --format json mysql mariadb 2>/dev/null | jq -r 'if type == "array" then .[] else . end | .Name' 2>/dev/null | head -n 1 || true) - [ -z "$container_name" ] && container_name=$(docker ps --filter "name=${APP_NAME}" --filter "name=mysql" --format '{{.ID}}' 2>/dev/null | head -n 1 || true) - [ -z "$container_name" ] && container_name=$(docker ps --filter "name=${APP_NAME}" --filter "name=mariadb" --format '{{.ID}}' 2>/dev/null | head -n 1 || true) - [ -z "$container_name" ] && container_name="mysql" - ;; - postgresql|timescaledb) - container_name=$($COMPOSE -f "$COMPOSE_FILE" -p "$APP_NAME" ps -q timescaledb 2>/dev/null || true) - [ -z "$container_name" ] && container_name=$($COMPOSE -f "$COMPOSE_FILE" -p "$APP_NAME" ps -q postgresql 2>/dev/null || true) - [ -z "$container_name" ] && container_name=$($COMPOSE -f "$COMPOSE_FILE" -p "$APP_NAME" ps --format json timescaledb postgresql 2>/dev/null | jq -r 'if type == "array" then .[] else . end | .Name' 2>/dev/null | head -n 1 || true) - [ -z "$container_name" ] && container_name="${APP_NAME}-timescaledb-1" - ;; - esac - echo "$container_name" -} - -check_container() { - local container_name=$1 - local db_type=$2 - local actual_container="" - - if docker inspect "$container_name" >/dev/null 2>&1; then - actual_container="$container_name" - else - case $db_type in - mariadb) - actual_container=$($COMPOSE -f "$COMPOSE_FILE" -p "$APP_NAME" ps -q mariadb 2>/dev/null || true) - [ -z "$actual_container" ] && [ -f "$COMPOSE_FILE" ] && actual_container="${APP_NAME}-mariadb-1" - ;; - mysql) - actual_container=$($COMPOSE -f "$COMPOSE_FILE" -p "$APP_NAME" ps -q mysql 2>/dev/null || true) - [ -z "$actual_container" ] && actual_container=$($COMPOSE -f "$COMPOSE_FILE" -p "$APP_NAME" ps -q mariadb 2>/dev/null || true) - [ -z "$actual_container" ] && [ -f "$COMPOSE_FILE" ] && actual_container="${APP_NAME}-mysql-1" - ;; - postgresql|timescaledb) - actual_container=$($COMPOSE -f "$COMPOSE_FILE" -p "$APP_NAME" ps -q postgresql 2>/dev/null || true) - [ -z "$actual_container" ] && actual_container=$($COMPOSE -f "$COMPOSE_FILE" -p "$APP_NAME" ps -q timescaledb 2>/dev/null || true) - [ -z "$actual_container" ] && [ -f "$COMPOSE_FILE" ] && actual_container="${APP_NAME}-postgresql-1" - ;; - esac - fi - - [ -z "$actual_container" ] && { echo ""; return 1; } - container_name="$actual_container" - docker ps --filter "id=${container_name}" --format '{{.ID}}' 2>/dev/null | grep -q . || \ - docker ps --filter "name=${container_name}" --format '{{.Names}}' 2>/dev/null | grep -q . || \ - docker ps --format '{{.Names}}' 2>/dev/null | grep -qE "^${container_name}$|/${container_name}$" || \ - docker ps --format '{{.ID}}' 2>/dev/null | grep -q "^${container_name}" || { echo ""; return 1; } - echo "$container_name" - return 0 -} - -verify_and_start_container() { - local container_name=$1 - local db_type=$2 - local actual_container="" - - if docker inspect "$container_name" >/dev/null 2>&1; then - actual_container="$container_name" - else - case $db_type in - mariadb) - actual_container=$($COMPOSE -f "$COMPOSE_FILE" -p "$APP_NAME" ps -q mariadb 2>/dev/null || true) - [ -z "$actual_container" ] && [ -f "$COMPOSE_FILE" ] && actual_container="${APP_NAME}-mariadb-1" - ;; - mysql) - actual_container=$($COMPOSE -f "$COMPOSE_FILE" -p "$APP_NAME" ps -q mysql 2>/dev/null || true) - [ -z "$actual_container" ] && actual_container=$($COMPOSE -f "$COMPOSE_FILE" -p "$APP_NAME" ps -q mariadb 2>/dev/null || true) - [ -z "$actual_container" ] && [ -f "$COMPOSE_FILE" ] && actual_container="${APP_NAME}-mysql-1" - ;; - postgresql|timescaledb) - actual_container=$($COMPOSE -f "$COMPOSE_FILE" -p "$APP_NAME" ps -q postgresql 2>/dev/null || true) - [ -z "$actual_container" ] && actual_container=$($COMPOSE -f "$COMPOSE_FILE" -p "$APP_NAME" ps -q timescaledb 2>/dev/null || true) - [ -z "$actual_container" ] && [ -f "$COMPOSE_FILE" ] && actual_container="${APP_NAME}-postgresql-1" - ;; - esac - fi - - [ -z "$actual_container" ] && { echo ""; return 1; } - container_name="$actual_container" - local container_running=false - docker ps --filter "id=${container_name}" --format '{{.ID}}' 2>/dev/null | grep -q . && container_running=true || \ - docker ps --filter "name=${container_name}" --format '{{.Names}}' 2>/dev/null | grep -q . && container_running=true || \ - docker ps --format '{{.Names}}' 2>/dev/null | grep -qE "^${container_name}$|/${container_name}$" && container_running=true || \ - docker ps --format '{{.ID}}' 2>/dev/null | grep -q "^${container_name}" && container_running=true - - if [ "$container_running" = false ]; then - colorized_echo yellow "Database container '$container_name' is not running. Attempting to start it..." - docker start "$container_name" >/dev/null 2>&1 || \ - $COMPOSE -f "$COMPOSE_FILE" -p "$APP_NAME" start "${db_type%%|*}" 2>/dev/null || true - sleep 2 - docker ps --filter "id=${container_name}" --format '{{.ID}}' 2>/dev/null | grep -q . && container_running=true || \ - docker ps --filter "name=${container_name}" --format '{{.Names}}' 2>/dev/null | grep -q . && container_running=true - fi - - [ "$container_running" = true ] && { echo "$container_name"; return 0; } || { echo ""; return 1; } -} - -install_pasarguard_script() { - FETCH_REPO="PasarGuard/scripts" - SCRIPT_URL="https://github.com/$FETCH_REPO/raw/main/pasarguard.sh" - colorized_echo blue "Installing pasarguard script" - curl -sSL $SCRIPT_URL | install -m 755 /dev/stdin /usr/local/bin/pasarguard - colorized_echo green "pasarguard script installed successfully" -} - -is_pasarguard_installed() { - if [ -d $APP_DIR ]; then - return 0 - else - return 1 - fi -} - -identify_the_operating_system_and_architecture() { - if [[ "$(uname)" == 'Linux' ]]; then - case "$(uname -m)" in - 'i386' | 'i686') - ARCH='32' - ;; - 'amd64' | 'x86_64') - ARCH='64' - ;; - 'armv5tel') - ARCH='arm32-v5' - ;; - 'armv6l') - ARCH='arm32-v6' - grep Features /proc/cpuinfo | grep -qw 'vfp' || ARCH='arm32-v5' - ;; - 'armv7' | 'armv7l') - ARCH='arm32-v7a' - grep Features /proc/cpuinfo | grep -qw 'vfp' || ARCH='arm32-v5' - ;; - 'armv8' | 'aarch64') - ARCH='arm64-v8a' - ;; - 'mips') - ARCH='mips32' - ;; - 'mipsle') - ARCH='mips32le' - ;; - 'mips64') - ARCH='mips64' - lscpu | grep -q "Little Endian" && ARCH='mips64le' - ;; - 'mips64le') - ARCH='mips64le' - ;; - 'ppc64') - ARCH='ppc64' - ;; - 'ppc64le') - ARCH='ppc64le' - ;; - 'riscv64') - ARCH='riscv64' - ;; - 's390x') - ARCH='s390x' - ;; - *) - echo "error: The architecture is not supported." - exit 1 - ;; - esac - else - echo "error: This operating system is not supported." - exit 1 - fi -} - -send_backup_to_telegram() { - if [ -f "$ENV_FILE" ]; then - while IFS='=' read -r key value; do - if [[ -z "$key" || "$key" =~ ^# ]]; then - continue - fi - key=$(echo "$key" | xargs) - value=$(echo "$value" | xargs) - value=$(echo "$value" | sed -E 's/^["'"'"'](.*)["'"'"']$/\1/') - if [[ "$key" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]]; then - export "$key"="$value" - else - colorized_echo yellow "Skipping invalid line in .env: $key=$value" - fi - done <"$ENV_FILE" - else - colorized_echo red "Environment file (.env) not found." - exit 1 - fi - - if [ "$BACKUP_SERVICE_ENABLED" != "true" ]; then - colorized_echo yellow "Backup service is not enabled. Skipping Telegram upload." - return - fi - - # Validate Telegram configuration - if [ -z "$BACKUP_TELEGRAM_BOT_KEY" ]; then - colorized_echo red "Error: BACKUP_TELEGRAM_BOT_KEY is not set in .env file" - return 1 - fi - - if [ -z "$BACKUP_TELEGRAM_CHAT_ID" ]; then - colorized_echo red "Error: BACKUP_TELEGRAM_CHAT_ID is not set in .env file" - return 1 - fi - - local proxy_url="" - local curl_proxy_args=() - if proxy_url=$(get_backup_proxy_url); then - curl_proxy_args=(--proxy "$proxy_url") - fi - - local server_ip="$(curl "${curl_proxy_args[@]}" -4 -s --max-time 5 ifconfig.me 2>/dev/null | grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$')" - if [ -z "$server_ip" ]; then - server_ip=$(hostname -I 2>/dev/null | awk '{print $1}') - fi - if [ -z "$server_ip" ]; then - server_ip="Unknown IP" - fi - local backup_dir="$APP_DIR/backup" - local latest_backup=$(ls -t "$backup_dir" 2>/dev/null | head -n 1) - - if [ -z "$latest_backup" ]; then - colorized_echo red "No backups found to send." - return 1 - fi - - local backup_paths=() - local cleanup_dir="" - - local telegram_split_bytes=$((49 * 1000 * 1000)) - - if [[ "$latest_backup" =~ \.part[0-9]{2}\.zip$ ]]; then - local base="${latest_backup%%.part*}" - while IFS= read -r file; do - [ -n "$file" ] && backup_paths+=("$file") - done < <(find "$backup_dir" -maxdepth 1 -type f -name "${base}.part*.zip" | sort) - if [ ${#backup_paths[@]} -eq 0 ]; then - colorized_echo red "Incomplete backup parts for $base" - return 1 - fi - elif [[ "$latest_backup" =~ \.z[0-9]{2}$ ]]; then - local base="${latest_backup%.z??}" - while IFS= read -r file; do - [ -n "$file" ] && backup_paths+=("$file") - done < <(find "$backup_dir" -maxdepth 1 -type f -name "${base}.z[0-9][0-9]" | sort) - if [ -f "$backup_dir/${base}.zip" ]; then - backup_paths+=("$backup_dir/${base}.zip") - else - colorized_echo red "Missing final .zip file for split archive $base" - return 1 - fi - elif [[ "$latest_backup" =~ \.zip$ ]]; then - local base="${latest_backup%.zip}" - local split_files=() - while IFS= read -r file; do - [ -n "$file" ] && split_files+=("$file") - done < <(find "$backup_dir" -maxdepth 1 -type f -name "${base}.z[0-9][0-9]" | sort) - if [ ${#split_files[@]} -gt 0 ]; then - backup_paths=("${split_files[@]}") - fi - backup_paths+=("$backup_dir/$latest_backup") - elif [[ "$latest_backup" =~ \.tar\.gz$ ]]; then - cleanup_dir="/tmp/pasarguard_backup_split" - rm -rf "$cleanup_dir" - mkdir -p "$cleanup_dir" - local legacy_backup="$backup_dir/$latest_backup" - local backup_size=$(du -m "$legacy_backup" | cut -f1) - if [ "$backup_size" -gt 49 ]; then - colorized_echo yellow "Legacy backup is larger than 49MB. Splitting before upload..." - split -b "$telegram_split_bytes" "$legacy_backup" "$cleanup_dir/${latest_backup}_part_" - else - cp "$legacy_backup" "$cleanup_dir/$latest_backup" - fi - while IFS= read -r file; do - [ -n "$file" ] && backup_paths+=("$file") - done < <(find "$cleanup_dir" -maxdepth 1 -type f -print | sort) - if [ ${#backup_paths[@]} -eq 0 ]; then - colorized_echo red "Failed to prepare legacy backup for upload." - rm -rf "$cleanup_dir" - return 1 - fi - else - colorized_echo red "Unsupported backup format: $latest_backup" - return 1 - fi - - local backup_time=$(date "+%Y-%m-%d %H:%M:%S %Z") - - for part in "${backup_paths[@]}"; do - local part_name=$(basename "$part") - local custom_filename="$part_name" - - local escaped_server_ip=$(printf '%s' "$server_ip" | sed 's/[_*\[\]()~`>#+\-=|{}!.]/\\&/g') - local escaped_filename=$(printf '%s' "$custom_filename" | sed 's/[_*\[\]()~`>#+\-=|{}!.]/\\&/g') - local escaped_time=$(printf '%s' "$backup_time" | sed 's/[_*\[\]()~`>#+\-=|{}!.]/\\&/g') - local caption="šŸ“¦ *Backup Information*\n🌐 *Server IP*: \`$escaped_server_ip\`\nšŸ“ *Backup File*: \`$escaped_filename\`\nā° *Backup Time*: \`$escaped_time\`" - - local response=$(curl "${curl_proxy_args[@]}" -s -w "\n%{http_code}" -F chat_id="$BACKUP_TELEGRAM_CHAT_ID" \ - -F document=@"$part;filename=$custom_filename" \ - -F caption="$(printf '%b' "$caption")" \ - -F parse_mode="MarkdownV2" \ - "https://api.telegram.org/bot$BACKUP_TELEGRAM_BOT_KEY/sendDocument" 2>&1) - - local http_code=$(echo "$response" | tail -n1) - local response_body=$(echo "$response" | sed '$d') - - if [ "$http_code" == "200" ]; then - # Check if response contains "ok":true - if echo "$response_body" | grep -q '"ok":true'; then - colorized_echo green "Backup part $custom_filename successfully sent to Telegram." - else - # Extract error message from Telegram response - local error_msg=$(echo "$response_body" | grep -o '"description":"[^"]*"' | cut -d'"' -f4 || echo "Unknown error") - colorized_echo red "Failed to send backup part $custom_filename to Telegram: $error_msg" - echo "Telegram API status: $http_code" >&2 - echo "Telegram API Response: $response_body" >&2 - fi - else - local error_msg=$(echo "$response_body" | grep -o '"description":"[^"]*"' | cut -d'"' -f4 || echo "HTTP $http_code") - colorized_echo red "Failed to send backup part $custom_filename to Telegram: $error_msg" - echo "Telegram API Response: $response_body" >&2 - fi - done - - if [ ${#uploaded_files[@]} -gt 0 ]; then - local files_list="" - for file in "${uploaded_files[@]}"; do - files_list+="- $file"$'\n' - done - files_list="${files_list%$'\n'}" - - local info_message=$'šŸ“¦ Backup Upload Summary\n' - info_message+=$'──────────────────────\n' - info_message+="🌐 Server IP: $server_ip"$'\n' - info_message+="ā° Time: $backup_time"$'\n' - info_message+=$'\nāœ… Files Uploaded:\n' - info_message+="$files_list"$'\n' - info_message+=$'\nšŸ“‚ Extraction Guide:\n' - info_message+=$'🪟 Windows: Install and use 7-Zip. Place the .zip and every .zXX part together, then start extraction from the .zip file.\n' - info_message+=$'🐧 Linux: Run unzip (e.g., unzip backup_xxx.zip) with all .zXX parts in the same directory.\n' - info_message+=$'šŸŽ macOS: Use Archive Utility or run unzip backup_xxx.zip from Terminal with the .zXX parts beside the .zip file.\n' - info_message+=$'āš ļø Always download the .zip and every .zXX part before extracting.' - - curl "${curl_proxy_args[@]}" -s -X POST "https://api.telegram.org/bot$BACKUP_TELEGRAM_BOT_KEY/sendMessage" \ - -d chat_id="$BACKUP_TELEGRAM_CHAT_ID" \ - -d text="$info_message" >/dev/null 2>&1 || true - fi - - if [ -n "$cleanup_dir" ]; then - rm -rf "$cleanup_dir" - fi -} - -send_backup_error_to_telegram() { - local error_messages=$1 - local log_file=$2 - local proxy_url="" - local curl_proxy_args=() - if proxy_url=$(get_backup_proxy_url); then - curl_proxy_args=(--proxy "$proxy_url") - fi - local server_ip="$(curl "${curl_proxy_args[@]}" -4 -s --max-time 5 ifconfig.me 2>/dev/null | grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$')" - if [ -z "$server_ip" ]; then - server_ip=$(hostname -I 2>/dev/null | awk '{print $1}') - fi - if [ -z "$server_ip" ]; then - server_ip="Unknown IP" - fi - local error_time=$(date "+%Y-%m-%d %H:%M:%S %Z") - local message="āš ļø Backup Error Notification -🌐 Server IP: $server_ip -āŒ Errors: $error_messages -ā° Time: $error_time" - - local max_length=1000 - if [ ${#message} -gt $max_length ]; then - message="${message:0:$((max_length - 25))}... -[Message truncated]" - fi - - curl "${curl_proxy_args[@]}" -s -X POST "https://api.telegram.org/bot$BACKUP_TELEGRAM_BOT_KEY/sendMessage" \ - -d chat_id="$BACKUP_TELEGRAM_CHAT_ID" \ - -d text="$message" >/dev/null 2>&1 && - colorized_echo green "Backup error notification sent to Telegram." || - colorized_echo red "Failed to send error notification to Telegram." - - if [ -f "$log_file" ]; then - - response=$(curl "${curl_proxy_args[@]}" -s -w "%{http_code}" -o /tmp/tg_response.json \ - -F chat_id="$BACKUP_TELEGRAM_CHAT_ID" \ - -F document=@"$log_file;filename=backup_error.log" \ - -F caption="šŸ“œ Backup Error Log - $error_time" \ - "https://api.telegram.org/bot$BACKUP_TELEGRAM_BOT_KEY/sendDocument") - - http_code="${response:(-3)}" - if [ "$http_code" -eq 200 ]; then - colorized_echo green "Backup error log sent to Telegram." - else - colorized_echo red "Failed to send backup error log to Telegram. HTTP code: $http_code" - cat /tmp/tg_response.json - fi - else - colorized_echo red "Log file not found: $log_file" - fi -} - -backup_service() { - local telegram_bot_key="" - local telegram_chat_id="" - local cron_schedule="" - local interval_hours="" - local backup_proxy_enabled="false" - local backup_proxy_url="" - - colorized_echo blue "=====================================" - colorized_echo blue " Welcome to Backup Service " - colorized_echo blue "=====================================" - - if grep -q "BACKUP_SERVICE_ENABLED=true" "$ENV_FILE"; then - while true; do - telegram_bot_key=$(awk -F'=' '/^BACKUP_TELEGRAM_BOT_KEY=/ {print $2}' "$ENV_FILE") - telegram_chat_id=$(awk -F'=' '/^BACKUP_TELEGRAM_CHAT_ID=/ {print $2}' "$ENV_FILE") - cron_schedule=$(awk -F'=' '/^BACKUP_CRON_SCHEDULE=/ {print $2}' "$ENV_FILE" | tr -d '"') - backup_proxy_enabled=$(awk -F'=' '/^BACKUP_PROXY_ENABLED=/ {print $2}' "$ENV_FILE") - backup_proxy_url=$(awk -F'=' '/^BACKUP_PROXY_URL=/ {print substr($0, index($0,"=")+1); exit}' "$ENV_FILE") - backup_proxy_url=$(echo "$backup_proxy_url" | sed -e 's/^"//' -e 's/"$//') - [ -z "$backup_proxy_enabled" ] && backup_proxy_enabled="false" - - if [[ "$cron_schedule" == "0 0 * * *" ]]; then - interval_hours=24 - else - interval_hours=$(echo "$cron_schedule" | grep -oP '(?<=\*/)[0-9]+') - fi - - colorized_echo green "=====================================" - colorized_echo green "Current Backup Configuration:" - colorized_echo cyan "Telegram Bot API Key: $telegram_bot_key" - colorized_echo cyan "Telegram Chat ID: $telegram_chat_id" - colorized_echo cyan "Backup Interval: Every $interval_hours hour(s)" - if [[ "$backup_proxy_enabled" == "true" && -n "$backup_proxy_url" ]]; then - colorized_echo cyan "Proxy: Enabled ($backup_proxy_url)" - else - colorized_echo cyan "Proxy: Disabled" - fi - colorized_echo green "=====================================" - echo "Choose an option:" - echo "1. Check Backup Service" - echo "2. Edit Backup Service" - echo "3. Reconfigure Backup Service" - echo "4. Remove Backup Service" - echo "5. Request Instant Backup" - echo "6. Exit" - read -p "Enter your choice (1-6): " user_choice - - case $user_choice in - 1) - view_backup_service - echo "" - ;; - 2) - edit_backup_service - echo "" - ;; - 3) - colorized_echo yellow "Starting reconfiguration..." - remove_backup_service - break - ;; - 4) - colorized_echo yellow "Removing Backup Service..." - remove_backup_service - return - ;; - 5) - colorized_echo yellow "Starting instant backup..." - backup_command - colorized_echo green "Instant backup completed." - echo "" - ;; - 6) - colorized_echo yellow "Exiting..." - return - ;; - *) - colorized_echo red "Invalid choice. Please try again." - echo "" - ;; - esac - done - else - colorized_echo yellow "No backup service is currently configured." - fi - - while true; do - printf "Enter your Telegram bot API key: " - read telegram_bot_key - if [[ -n "$telegram_bot_key" ]]; then - break - else - colorized_echo red "API key cannot be empty. Please try again." - fi - done - - while true; do - printf "Enter your Telegram chat ID: " - read telegram_chat_id - if [[ -n "$telegram_chat_id" ]]; then - break - else - colorized_echo red "Chat ID cannot be empty. Please try again." - fi - done - - while true; do - printf "Set up the backup interval in hours (1-24):\n" - read interval_hours - - if ! [[ "$interval_hours" =~ ^[0-9]+$ ]]; then - colorized_echo red "Invalid input. Please enter a valid number." - continue - fi - - if [[ "$interval_hours" -eq 24 ]]; then - cron_schedule="0 0 * * *" - colorized_echo green "Setting backup to run daily at midnight." - break - fi - - if [[ "$interval_hours" -ge 1 && "$interval_hours" -le 23 ]]; then - cron_schedule="0 */$interval_hours * * *" - colorized_echo green "Setting backup to run every $interval_hours hour(s)." - break - else - colorized_echo red "Invalid input. Please enter a number between 1-24." - fi - done - - while true; do - read -p "Do you need to use an HTTP/SOCKS proxy for Telegram backups? (y/N): " proxy_choice - case "$proxy_choice" in - [Yy]*) - backup_proxy_enabled="true" - break - ;; - [Nn]*|"") - backup_proxy_enabled="false" - break - ;; - *) - colorized_echo red "Invalid choice. Please enter y or n." - ;; - esac - done - - if [ "$backup_proxy_enabled" = "true" ]; then - while true; do - read -p "Enter proxy URL (e.g. http://127.0.0.1:8080 or socks5://127.0.0.1:1080): " backup_proxy_url - backup_proxy_url=$(echo "$backup_proxy_url" | xargs) - if [ -z "$backup_proxy_url" ]; then - colorized_echo red "Proxy URL cannot be empty." - continue - fi - if is_valid_proxy_url "$backup_proxy_url"; then - break - else - colorized_echo red "Invalid proxy URL. Supported prefixes: http://, https://, socks5://, socks5h://, socks4://." - fi - done - else - backup_proxy_url="" - fi - - sed -i '/^BACKUP_SERVICE_ENABLED/d' "$ENV_FILE" - sed -i '/^BACKUP_TELEGRAM_BOT_KEY/d' "$ENV_FILE" - sed -i '/^BACKUP_TELEGRAM_CHAT_ID/d' "$ENV_FILE" - sed -i '/^BACKUP_CRON_SCHEDULE/d' "$ENV_FILE" - sed -i '/^BACKUP_PROXY_ENABLED/d' "$ENV_FILE" - sed -i '/^BACKUP_PROXY_URL/d' "$ENV_FILE" - - { - echo "" - echo "# Backup service configuration" - echo "BACKUP_SERVICE_ENABLED=true" - echo "BACKUP_TELEGRAM_BOT_KEY=$telegram_bot_key" - echo "BACKUP_TELEGRAM_CHAT_ID=$telegram_chat_id" - echo "BACKUP_CRON_SCHEDULE=\"$cron_schedule\"" - echo "BACKUP_PROXY_ENABLED=$backup_proxy_enabled" - echo "BACKUP_PROXY_URL=\"$backup_proxy_url\"" - } >>"$ENV_FILE" - - colorized_echo green "Backup service configuration saved in $ENV_FILE." - - # Use full path to the script for cron job - local script_path="/usr/local/bin/$APP_NAME" - if [ ! -f "$script_path" ]; then - script_path=$(which "$APP_NAME" 2>/dev/null || echo "/usr/local/bin/$APP_NAME") - fi - # Set PATH for cron to ensure docker and other tools are found - local backup_command="PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin bash $script_path backup" - add_cron_job "$cron_schedule" "$backup_command" - - colorized_echo green "Backup service successfully configured." - - # Run initial backup - colorized_echo blue "Running initial backup..." - backup_command - if [ $? -eq 0 ]; then - colorized_echo green "Initial backup completed successfully." - else - colorized_echo yellow "Initial backup completed with warnings. Check logs if needed." - fi - if [[ "$interval_hours" -eq 24 ]]; then - colorized_echo cyan "Backups will be sent to Telegram daily (every 24 hours at midnight)." - else - colorized_echo cyan "Backups will be sent to Telegram every $interval_hours hour(s)." - fi - colorized_echo green "=====================================" -} - -add_cron_job() { - local schedule="$1" - local command="$2" - local temp_cron=$(mktemp) - - crontab -l 2>/dev/null >"$temp_cron" || true - grep -v "$command" "$temp_cron" >"${temp_cron}.tmp" && mv "${temp_cron}.tmp" "$temp_cron" - echo "$schedule $command # pasarguard-backup-service" >>"$temp_cron" - - if crontab "$temp_cron"; then - colorized_echo green "Cron job successfully added." - else - colorized_echo red "Failed to add cron job. Please check manually." - fi - rm -f "$temp_cron" -} - -view_backup_service() { - if ! grep -q "BACKUP_SERVICE_ENABLED=true" "$ENV_FILE"; then - colorized_echo red "Backup service is not configured." - return 1 - fi - - local telegram_bot_key=$(awk -F'=' '/^BACKUP_TELEGRAM_BOT_KEY=/ {print $2}' "$ENV_FILE") - local telegram_chat_id=$(awk -F'=' '/^BACKUP_TELEGRAM_CHAT_ID=/ {print $2}' "$ENV_FILE") - local cron_schedule=$(awk -F'=' '/^BACKUP_CRON_SCHEDULE=/ {print $2}' "$ENV_FILE" | tr -d '"') - local backup_proxy_enabled=$(awk -F'=' '/^BACKUP_PROXY_ENABLED=/ {print $2}' "$ENV_FILE") - local backup_proxy_url=$(awk -F'=' '/^BACKUP_PROXY_URL=/ {print substr($0, index($0,"=")+1); exit}' "$ENV_FILE") - backup_proxy_url=$(echo "$backup_proxy_url" | sed -e 's/^"//' -e 's/"$//') - [ -z "$backup_proxy_enabled" ] && backup_proxy_enabled="false" - local interval_hours="" - - if [[ "$cron_schedule" == "0 0 * * *" ]]; then - interval_hours=24 - else - interval_hours=$(echo "$cron_schedule" | grep -oP '(?<=\*/)[0-9]+') - fi - - colorized_echo blue "=====================================" - colorized_echo blue " Backup Service Details " - colorized_echo blue "=====================================" - colorized_echo green "Status: Enabled" - colorized_echo cyan "Telegram Bot API Key: $telegram_bot_key" - colorized_echo cyan "Telegram Chat ID: $telegram_chat_id" - colorized_echo cyan "Cron Schedule: $cron_schedule" - if [[ "$interval_hours" -eq 24 ]]; then - colorized_echo cyan "Backup Interval: Daily at midnight (every 24 hours)" - else - colorized_echo cyan "Backup Interval: Every $interval_hours hour(s)" - fi - if [[ "$backup_proxy_enabled" == "true" && -n "$backup_proxy_url" ]]; then - colorized_echo cyan "Proxy: Enabled ($backup_proxy_url)" - else - colorized_echo cyan "Proxy: Disabled" - fi - colorized_echo blue "=====================================" - echo "" - read -p "Press Enter to continue..." -} - -edit_backup_service() { - if ! grep -q "BACKUP_SERVICE_ENABLED=true" "$ENV_FILE"; then - colorized_echo red "Backup service is not configured." - return 1 - fi - - local telegram_bot_key=$(awk -F'=' '/^BACKUP_TELEGRAM_BOT_KEY=/ {print $2}' "$ENV_FILE") - local telegram_chat_id=$(awk -F'=' '/^BACKUP_TELEGRAM_CHAT_ID=/ {print $2}' "$ENV_FILE") - local cron_schedule=$(awk -F'=' '/^BACKUP_CRON_SCHEDULE=/ {print $2}' "$ENV_FILE" | tr -d '"') - local backup_proxy_enabled=$(awk -F'=' '/^BACKUP_PROXY_ENABLED=/ {print $2}' "$ENV_FILE") - local backup_proxy_url=$(awk -F'=' '/^BACKUP_PROXY_URL=/ {print substr($0, index($0,"=")+1); exit}' "$ENV_FILE") - backup_proxy_url=$(echo "$backup_proxy_url" | sed -e 's/^"//' -e 's/"$//') - [ -z "$backup_proxy_enabled" ] && backup_proxy_enabled="false" - local interval_hours="" - - if [[ "$cron_schedule" == "0 0 * * *" ]]; then - interval_hours=24 - else - interval_hours=$(echo "$cron_schedule" | grep -oP '(?<=\*/)[0-9]+') - fi - - colorized_echo blue "=====================================" - colorized_echo blue " Edit Backup Service " - colorized_echo blue "=====================================" - echo "Current configuration:" - local proxy_display="Disabled" - if [[ "$backup_proxy_enabled" == "true" && -n "$backup_proxy_url" ]]; then - proxy_display="Enabled ($backup_proxy_url)" - fi - colorized_echo cyan "1. Telegram Bot API Key: $telegram_bot_key" - colorized_echo cyan "2. Telegram Chat ID: $telegram_chat_id" - colorized_echo cyan "3. Backup Interval: Every $interval_hours hour(s)" - colorized_echo cyan "4. Proxy: $proxy_display" - colorized_echo yellow "5. Cancel" - echo "" - read -p "Which setting would you like to edit? (1-5): " edit_choice - - case $edit_choice in - 1) - while true; do - printf "Enter new Telegram bot API key [current: $telegram_bot_key]: " - read new_bot_key - if [[ -n "$new_bot_key" ]]; then - sed -i "s|^BACKUP_TELEGRAM_BOT_KEY=.*|BACKUP_TELEGRAM_BOT_KEY=$new_bot_key|" "$ENV_FILE" - colorized_echo green "Telegram Bot API Key updated successfully." - break - else - colorized_echo red "API key cannot be empty. Please try again." - fi - done - ;; - 2) - while true; do - printf "Enter new Telegram chat ID [current: $telegram_chat_id]: " - read new_chat_id - if [[ -n "$new_chat_id" ]]; then - sed -i "s|^BACKUP_TELEGRAM_CHAT_ID=.*|BACKUP_TELEGRAM_CHAT_ID=$new_chat_id|" "$ENV_FILE" - colorized_echo green "Telegram Chat ID updated successfully." - break - else - colorized_echo red "Chat ID cannot be empty. Please try again." - fi - done - ;; - 3) - while true; do - printf "Set new backup interval in hours (1-24) [current: $interval_hours]:\n" - read new_interval_hours - - if ! [[ "$new_interval_hours" =~ ^[0-9]+$ ]]; then - colorized_echo red "Invalid input. Please enter a valid number." - continue - fi - - local new_cron_schedule="" - if [[ "$new_interval_hours" -eq 24 ]]; then - new_cron_schedule="0 0 * * *" - colorized_echo green "Setting backup to run daily at midnight." - elif [[ "$new_interval_hours" -ge 1 && "$new_interval_hours" -le 23 ]]; then - new_cron_schedule="0 */$new_interval_hours * * *" - colorized_echo green "Setting backup to run every $new_interval_hours hour(s)." - else - colorized_echo red "Invalid input. Please enter a number between 1-24." - continue - fi - - sed -i "s|^BACKUP_CRON_SCHEDULE=.*|BACKUP_CRON_SCHEDULE=\"$new_cron_schedule\"|" "$ENV_FILE" - - # Use full path to the script for cron job - local script_path="/usr/local/bin/$APP_NAME" - if [ ! -f "$script_path" ]; then - script_path=$(which "$APP_NAME" 2>/dev/null || echo "/usr/local/bin/$APP_NAME") - fi - # Set PATH for cron to ensure docker and other tools are found - local backup_command="PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin bash $script_path backup" - local temp_cron=$(mktemp) - crontab -l 2>/dev/null >"$temp_cron" || true - grep -v "# pasarguard-backup-service" "$temp_cron" >"${temp_cron}.tmp" && mv "${temp_cron}.tmp" "$temp_cron" - echo "$new_cron_schedule $backup_command # pasarguard-backup-service" >>"$temp_cron" - - if crontab "$temp_cron"; then - colorized_echo green "Backup interval and cron schedule updated successfully." - else - colorized_echo red "Failed to update cron job. Please check manually." - fi - rm -f "$temp_cron" - break - done - ;; - 4) - local new_proxy_enabled="$backup_proxy_enabled" - local new_proxy_url="$backup_proxy_url" - while true; do - read -p "Enable proxy for Telegram backups? (y/N) [current: $proxy_display]: " proxy_choice - case "$proxy_choice" in - [Yy]*) - new_proxy_enabled="true" - break - ;; - [Nn]*|"") - new_proxy_enabled="false" - break - ;; - *) - colorized_echo red "Invalid choice. Please enter y or n." - ;; - esac - done - - if [ "$new_proxy_enabled" = "true" ]; then - while true; do - read -p "Enter proxy URL (e.g. http://127.0.0.1:8080 or socks5://127.0.0.1:1080) [current: $backup_proxy_url]: " input_proxy_url - if [ -z "$input_proxy_url" ]; then - if [ -n "$backup_proxy_url" ]; then - input_proxy_url="$backup_proxy_url" - else - colorized_echo red "Proxy URL cannot be empty." - continue - fi - fi - input_proxy_url=$(echo "$input_proxy_url" | xargs) - if is_valid_proxy_url "$input_proxy_url"; then - new_proxy_url="$input_proxy_url" - break - else - colorized_echo red "Invalid proxy URL. Supported prefixes: http://, https://, socks5://, socks5h://, socks4://." - fi - done - else - new_proxy_url="" - fi - - replace_or_append_env_var "BACKUP_PROXY_ENABLED" "$new_proxy_enabled" - replace_or_append_env_var "BACKUP_PROXY_URL" "$new_proxy_url" true - colorized_echo green "Backup proxy configuration updated successfully." - ;; - 5) - colorized_echo yellow "Edit cancelled." - return - ;; - *) - colorized_echo red "Invalid choice." - return - ;; - esac - - colorized_echo green "Backup service configuration updated successfully." -} - -remove_backup_service() { - colorized_echo red "in process..." - - sed -i '/^# Backup service configuration/d' "$ENV_FILE" - sed -i '/BACKUP_SERVICE_ENABLED/d' "$ENV_FILE" - sed -i '/BACKUP_TELEGRAM_BOT_KEY/d' "$ENV_FILE" - sed -i '/BACKUP_TELEGRAM_CHAT_ID/d' "$ENV_FILE" - sed -i '/BACKUP_CRON_SCHEDULE/d' "$ENV_FILE" - sed -i '/BACKUP_PROXY_ENABLED/d' "$ENV_FILE" - sed -i '/BACKUP_PROXY_URL/d' "$ENV_FILE" - - local temp_cron=$(mktemp) - crontab -l 2>/dev/null >"$temp_cron" - - sed -i '/# pasarguard-backup-service/d' "$temp_cron" - - if crontab "$temp_cron"; then - colorized_echo green "Backup service task removed from crontab." - else - colorized_echo red "Failed to update crontab. Please check manually." - fi - - rm -f "$temp_cron" - - colorized_echo green "Backup service has been removed." -} - -restore_command() { - colorized_echo blue "Starting restore process..." - - # Check if pasarguard is installed - if ! is_pasarguard_installed; then - colorized_echo red "pasarguard's not installed!" - exit 1 - fi - - detect_compose - - if ! is_pasarguard_up; then - colorized_echo red "pasarguard is not up. Please start pasarguard first." - exit 1 - fi - - local current_db_user="" - local current_db_password="" - local current_db_name="" - local current_sqlalchemy_url="" - local current_mysql_root_password="" - - if [ -f "$ENV_FILE" ]; then - set +e - while IFS='=' read -r key value || [ -n "$key" ]; do - if [[ -z "$key" || "$key" =~ ^# ]]; then - continue - fi - key=$(echo "$key" | xargs 2>/dev/null || echo "$key" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') - value=$(echo "$value" | xargs 2>/dev/null || echo "$value" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') - value=$(echo "$value" | sed -E 's/^["'"'"'](.*)["'"'"']$/\1/' 2>/dev/null || echo "$value") - case "$key" in - MYSQL_ROOT_PASSWORD) - current_mysql_root_password="$value" - ;; - DB_USER) - current_db_user="$value" - ;; - DB_PASSWORD) - current_db_password="$value" - ;; - DB_NAME) - current_db_name="$value" - ;; - SQLALCHEMY_DATABASE_URL) - current_sqlalchemy_url="$value" - ;; - esac - done <"$ENV_FILE" - set -e - fi - - local backup_dir="$APP_DIR/backup" - local temp_restore_dir="/tmp/pasarguard_restore" - local log_file="/var/log/pasarguard_restore_error.log" - >"$log_file" - echo "Restore Log - $(date)" >>"$log_file" - - # Clean up temp directory - rm -rf "$temp_restore_dir" - mkdir -p "$temp_restore_dir" - - # Check if backup directory exists - if [ ! -d "$backup_dir" ]; then - colorized_echo red "Backup directory not found: $backup_dir" - exit 1 - fi - - # List available backup files (find all backup-related files in backup directory) - local backup_candidates=() - while IFS= read -r -d '' file; do - backup_candidates+=("$file") - done < <(find "$backup_dir" -maxdepth 1 \( -name "*backup*.gz" -o -name "*backup*.tar.gz" -o -name "*.tar.gz" -o -name "*backup*.zip" -o -name "*.zip" \) -type f -print0 2>/dev/null) - - if [ ${#backup_candidates[@]} -eq 0 ]; then - # Fallback: try to find any archive files - while IFS= read -r -d '' file; do - backup_candidates+=("$file") - done < <(find "$backup_dir" -maxdepth 1 \( -name "*.gz" -o -name "*.zip" \) -type f -print0 2>/dev/null) - fi - - local backup_files=() - for file in "${backup_candidates[@]}"; do - local filename=$(basename "$file") - if [[ "$filename" =~ \.part[0-9]{2}\.zip$ ]] && [[ ! "$filename" =~ \.part01\.zip$ ]]; then - continue - fi - if [[ "$filename" =~ \.z[0-9]{2}$ ]]; then - continue - fi - backup_files+=("$file") - done - - if [ ${#backup_files[@]} -eq 0 ]; then - colorized_echo red "No backup files found in $backup_dir" - colorized_echo yellow "Looking for files with extensions: .gz, .zip, .tar.gz or containing 'backup'" - exit 1 - fi - - colorized_echo blue "Available backup files:" - local i=1 - for file in "${backup_files[@]}"; do - if [ -f "$file" ]; then - local filename=$(basename "$file") - if [[ "$filename" =~ \.part[0-9]{2}\.zip$ ]]; then - local base_name="${filename%%.part*}" - local part_count=$(find "$backup_dir" -maxdepth 1 -type f -name "${base_name}.part*.zip" | wc -l | awk '{print $1}') - [ -z "$part_count" ] && part_count=0 - local total_size_bytes=0 - while IFS= read -r part_file; do - local part_size=$(stat -c%s "$part_file" 2>/dev/null || stat -f%z "$part_file" 2>/dev/null) - if [ -z "$part_size" ]; then - part_size=$(wc -c <"$part_file") - fi - total_size_bytes=$((total_size_bytes + part_size)) - done < <(find "$backup_dir" -maxdepth 1 -type f -name "${base_name}.part*.zip") - local human_size="" - if command -v numfmt >/dev/null 2>&1; then - human_size=$(numfmt --to=iec --suffix=B "$total_size_bytes" 2>/dev/null || awk -v size="$total_size_bytes" 'BEGIN { printf "%.2f MB", size/1048576 }') - else - human_size=$(awk -v size="$total_size_bytes" 'BEGIN { printf "%.2f MB", size/1048576 }') - fi - local file_date=$(date -r "$file" "+%Y-%m-%d %H:%M:%S") - echo "$i. $filename (Parts: ${part_count:-1}, Total Size: $human_size, Date: $file_date)" - elif [[ "$filename" =~ \.zip$ ]]; then - local base_name="${filename%.zip}" - local zip_part_files=() - while IFS= read -r part_file; do - zip_part_files+=("$part_file") - done < <(find "$backup_dir" -maxdepth 1 -type f -name "${base_name}.z[0-9][0-9]" | sort) - if [ ${#zip_part_files[@]} -gt 0 ]; then - local total_size_bytes=0 - for part_file in "${zip_part_files[@]}"; do - local part_size=$(stat -c%s "$part_file" 2>/dev/null || stat -f%z "$part_file" 2>/dev/null) - if [ -z "$part_size" ]; then - part_size=$(wc -c <"$part_file") - fi - total_size_bytes=$((total_size_bytes + part_size)) - done - local main_size=$(stat -c%s "$file" 2>/dev/null || stat -f%z "$file" 2>/dev/null) - if [ -z "$main_size" ]; then - main_size=$(wc -c <"$file") - fi - total_size_bytes=$((total_size_bytes + main_size)) - local part_display="" - if command -v numfmt >/dev/null 2>&1; then - part_display=$(numfmt --to=iec --suffix=B "$total_size_bytes" 2>/dev/null || awk -v size="$total_size_bytes" 'BEGIN { printf "%.2f MB", size/1048576 }') - else - part_display=$(awk -v size="$total_size_bytes" 'BEGIN { printf "%.2f MB", size/1048576 }') - fi - local file_date=$(date -r "$file" "+%Y-%m-%d %H:%M:%S") - local part_count=$(( ${#zip_part_files[@]} + 1 )) - echo "$i. $filename (Zip splits: $part_count parts, Total Size: $part_display, Date: $file_date)" - else - local file_size=$(du -h "$file" | cut -f1) - local file_date=$(date -r "$file" "+%Y-%m-%d %H:%M:%S") - echo "$i. $filename (Size: $file_size, Date: $file_date)" - fi - else - local file_size=$(du -h "$file" | cut -f1) - local file_date=$(date -r "$file" "+%Y-%m-%d %H:%M:%S") - echo "$i. $filename (Size: $file_size, Date: $file_date)" - fi - ((i++)) - fi - done - - local file_count=$((i-1)) - if [ "$file_count" -eq 0 ]; then - colorized_echo red "No valid backup files found." - exit 1 - fi - - # Select backup file - while true; do - printf "Select backup file to restore from (1-%d): " "$file_count" - read -r selection - if [[ "$selection" =~ ^[0-9]+$ ]] && [ "$selection" -ge 1 ] && [ "$selection" -le "$file_count" ]; then - break - else - colorized_echo red "Invalid selection. Please enter a number between 1 and $file_count." - fi - done - - local selected_file="${backup_files[$((selection-1))]}" - local selected_filename=$(basename "$selected_file") - - colorized_echo blue "Selected backup: $selected_filename" - - colorized_echo blue "Preparing archive for extraction..." - local archive_to_extract="$selected_file" - local archive_format="tar" - - if [[ "$selected_filename" =~ \.part[0-9]{2}\.zip$ ]]; then - archive_format="zip" - local base_name="${selected_filename%%.part*}" - colorized_echo yellow "Detected split zip backup. Checking available parts..." - if [ ! -f "$backup_dir/${base_name}.part01.zip" ]; then - colorized_echo red "Missing ${base_name}.part01.zip. Cannot restore split backup." - rm -rf "$temp_restore_dir" - exit 1 - fi - local concatenated_file="$temp_restore_dir/${base_name}_combined.zip" - >"$concatenated_file" - local part_count=0 - while IFS= read -r part_file; do - cat "$part_file" >>"$concatenated_file" - part_count=$((part_count + 1)) - done < <(find "$backup_dir" -maxdepth 1 -type f -name "${base_name}.part*.zip" | sort) - if [ "$part_count" -eq 0 ]; then - colorized_echo red "No parts found for $base_name" - rm -rf "$temp_restore_dir" - exit 1 - fi - archive_to_extract="$concatenated_file" - colorized_echo green "āœ“ Combined $part_count part(s)" - elif [[ "$selected_filename" =~ \.zip$ ]]; then - archive_format="zip" - local base_name="${selected_filename%.zip}" - local zip_split_parts=() - while IFS= read -r part_file; do - [ -n "$part_file" ] && zip_split_parts+=("$part_file") - done < <(find "$backup_dir" -maxdepth 1 -type f -name "${base_name}.z[0-9][0-9]" | sort) - - if [ ${#zip_split_parts[@]} -gt 0 ]; then - colorized_echo yellow "Detected split zip backup (.zXX + .zip). Rebuilding archive..." - local expected_part=1 - for part_file in "${zip_split_parts[@]}"; do - local expected_name - expected_name=$(printf "%s.z%02d" "$base_name" "$expected_part") - if [ "$(basename "$part_file")" != "$expected_name" ]; then - colorized_echo red "Missing split part $expected_name. Cannot restore split backup." - rm -rf "$temp_restore_dir" - exit 1 - fi - expected_part=$((expected_part + 1)) - done - - local concatenated_file="$temp_restore_dir/${base_name}_combined.zip" - if command -v zip >/dev/null 2>&1 && zip -s 0 "$selected_file" --out "$concatenated_file" >>"$log_file" 2>&1; then - archive_to_extract="$concatenated_file" - colorized_echo green "āœ“ Rebuilt split zip archive with zip utility" - else - if command -v zip >/dev/null 2>&1; then - colorized_echo yellow "zip rebuild failed. Falling back to direct concatenation..." - else - colorized_echo yellow "zip utility not found. Falling back to direct concatenation..." - fi - >"$concatenated_file" - local part_count=0 - for part_file in "${zip_split_parts[@]}"; do - if ! cat "$part_file" >>"$concatenated_file"; then - colorized_echo red "Failed to read split part: $(basename "$part_file")" - rm -rf "$temp_restore_dir" - exit 1 - fi - part_count=$((part_count + 1)) - done - if ! cat "$selected_file" >>"$concatenated_file"; then - colorized_echo red "Failed to read main zip file: $selected_filename" - rm -rf "$temp_restore_dir" - exit 1 - fi - archive_to_extract="$concatenated_file" - colorized_echo green "āœ“ Combined $((part_count + 1)) split part(s)" - fi - fi - else - archive_format="tar" - fi - - colorized_echo blue "Extracting backup..." - if [ "$archive_format" = "zip" ]; then - if ! command -v unzip >/dev/null 2>&1; then - detect_os - install_package unzip - fi - if ! unzip -tq "$archive_to_extract" >/dev/null 2>>"$log_file"; then - colorized_echo red "ERROR: The backup file is not a valid zip archive." - echo "File is not a valid zip archive: $archive_to_extract" >>"$log_file" - rm -rf "$temp_restore_dir" - exit 1 - fi - if ! unzip -oq "$archive_to_extract" -d "$temp_restore_dir" 2>>"$log_file"; then - colorized_echo red "Failed to extract backup file." - echo "Failed to extract $archive_to_extract" >>"$log_file" - rm -rf "$temp_restore_dir" - exit 1 - fi - else - if ! gzip -t "$archive_to_extract" 2>/dev/null; then - colorized_echo red "ERROR: The backup file is not a valid gzip archive." - echo "File is not a valid gzip archive: $archive_to_extract" >>"$log_file" - rm -rf "$temp_restore_dir" - exit 1 - fi - if ! tar -xzf "$archive_to_extract" -C "$temp_restore_dir" 2>>"$log_file"; then - colorized_echo red "Failed to extract backup file." - echo "Failed to extract $archive_to_extract" >>"$log_file" - rm -rf "$temp_restore_dir" - exit 1 - fi - fi - colorized_echo green "āœ“ Archive extracted successfully" - - # Load environment variables from extracted .env - colorized_echo blue "Loading configuration from backup..." - local extracted_env="$temp_restore_dir/.env" - if [ ! -f "$extracted_env" ]; then - colorized_echo red "Environment file not found in backup." - rm -rf "$temp_restore_dir" - exit 1 - fi - - local db_type="" - local sqlite_file="" - local db_host="" - local db_port="" - local db_user="" - local db_password="" - local db_name="" - local container_name="" - - # Load variables from extracted .env - # Check if file is readable - if [ ! -r "$extracted_env" ]; then - colorized_echo red "ERROR: .env file is not readable" - rm -rf "$temp_restore_dir" - exit 1 - fi - - # Check for binary content or null bytes (warning only, not fatal) - if grep -q $'\x00' "$extracted_env" 2>/dev/null; then - colorized_echo yellow "WARNING: .env file contains null bytes, cleaning..." - fi - - local env_vars_loaded=0 - - # Check if file has null bytes - if not, use it directly - local env_file_to_use="$extracted_env" - if grep -q $'\x00' "$extracted_env" 2>/dev/null; then - # File has null bytes, create cleaned version - local cleaned_env="/tmp/pasarguard_env_cleaned_$$" - set +e - tr -d '\000' < "$extracted_env" > "$cleaned_env" 2>/dev/null - local tr_result=$? - set -e - if [ $tr_result -eq 0 ] && [ -s "$cleaned_env" ]; then - env_file_to_use="$cleaned_env" - else - rm -f "$cleaned_env" - fi - fi - - # Use the EXACT same pattern as backup_command function - # This ensures compatibility and works in the current shell (no subshell) - colorized_echo blue "Loading environment variables..." - if [ -f "$env_file_to_use" ]; then - # Temporarily disable exit on error for the loop to handle failures gracefully - set +e - while IFS='=' read -r key value || [ -n "$key" ]; do - if [[ -z "$key" || "$key" =~ ^# ]]; then - continue - fi - # Trim whitespace from key and value - key=$(echo "$key" | xargs 2>/dev/null || echo "$key" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') - value=$(echo "$value" | xargs 2>/dev/null || echo "$value" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') - # Remove surrounding quotes from value if present - value=$(echo "$value" | sed -E 's/^["'\''](.*)["'\'']$/\1/' 2>/dev/null || echo "$value") - if [[ "$key" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]]; then - export "$key"="$value" 2>/dev/null || true - env_vars_loaded=$((env_vars_loaded + 1)) - else - echo "Skipping invalid line in .env: $key=$value" >&2 - fi - done <"$env_file_to_use" - set -e # Re-enable exit on error - else - colorized_echo red "Environment file (.env) not found in backup." - rm -rf "$temp_restore_dir" - exit 1 - fi - - # Clean up temporary cleaned file if we created one - if [ -n "${cleaned_env:-}" ] && [ -f "$cleaned_env" ]; then - rm -f "$cleaned_env" - fi - - colorized_echo green "āœ“ Loaded $env_vars_loaded environment variables" - - if [ -z "$SQLALCHEMY_DATABASE_URL" ]; then - colorized_echo red "SQLALCHEMY_DATABASE_URL not found in backup .env file" - colorized_echo yellow "Available environment variables:" - grep -v '^#' "$extracted_env" | grep '=' | cut -d'=' -f1 | head -10 - rm -rf "$temp_restore_dir" - exit 1 - fi - - colorized_echo green "āœ“ Found SQLALCHEMY_DATABASE_URL: ${SQLALCHEMY_DATABASE_URL:0:50}..." - - # Parse database configuration (similar to backup function) - colorized_echo blue "Detecting database type..." - if [[ "$SQLALCHEMY_DATABASE_URL" =~ ^sqlite ]]; then - db_type="sqlite" - colorized_echo green "āœ“ Detected SQLite database" - local sqlite_url_part="${SQLALCHEMY_DATABASE_URL#*://}" - sqlite_url_part="${sqlite_url_part%%\?*}" - sqlite_url_part="${sqlite_url_part%%#*}" - - if [[ "$sqlite_url_part" =~ ^//(.*)$ ]]; then - sqlite_file="/${BASH_REMATCH[1]}" - elif [[ "$sqlite_url_part" =~ ^/(.*)$ ]]; then - sqlite_file="/${BASH_REMATCH[1]}" - else - sqlite_file="$sqlite_url_part" - fi - colorized_echo blue "Database file: $sqlite_file" - elif [[ "$SQLALCHEMY_DATABASE_URL" =~ ^(mysql|mariadb|postgresql)[^:]*:// ]]; then - if [[ "$SQLALCHEMY_DATABASE_URL" =~ ^mariadb[^:]*:// ]]; then - db_type="mariadb" - colorized_echo green "āœ“ Detected MariaDB database" - elif [[ "$SQLALCHEMY_DATABASE_URL" =~ ^mysql[^:]*:// ]]; then - db_type="mysql" - colorized_echo green "āœ“ Detected MySQL database" - elif [[ "$SQLALCHEMY_DATABASE_URL" =~ ^postgresql[^:]*:// ]]; then - # Check if it's timescaledb - use set +e to prevent failure on file not found - set +e - if grep -q "image: timescale/timescaledb" "$temp_restore_dir/docker-compose.yml" 2>/dev/null; then - db_type="timescaledb" - colorized_echo green "āœ“ Detected TimescaleDB database" - else - db_type="postgresql" - colorized_echo green "āœ“ Detected PostgreSQL database" - fi - set -e - fi - - local url_part="${SQLALCHEMY_DATABASE_URL#*://}" - url_part="${url_part%%\?*}" - url_part="${url_part%%#*}" - - if [[ "$url_part" =~ ^([^@]+)@(.+)$ ]]; then - local auth_part="${BASH_REMATCH[1]}" - url_part="${BASH_REMATCH[2]}" - - if [[ "$auth_part" =~ ^([^:]+):(.+)$ ]]; then - db_user="${BASH_REMATCH[1]}" - db_password="${BASH_REMATCH[2]}" - else - db_user="$auth_part" - fi - fi - - if [[ "$url_part" =~ ^([^:/]+)(:([0-9]+))?/(.+)$ ]]; then - db_host="${BASH_REMATCH[1]}" - db_port="${BASH_REMATCH[3]:-}" - db_name="${BASH_REMATCH[4]}" - db_name="${db_name%%\?*}" - db_name="${db_name%%#*}" - - if [ -z "$db_port" ]; then - if [[ "$db_type" =~ ^(mysql|mariadb)$ ]]; then - db_port="3306" - elif [[ "$db_type" =~ ^(postgresql|timescaledb)$ ]]; then - db_port="5432" - fi - fi - fi - - # Find container name for local databases - if [[ "$db_host" == "127.0.0.1" || "$db_host" == "localhost" || "$db_host" == "::1" ]]; then - set +e - container_name=$(find_container "$db_type") - set -e - fi - fi - - if [ -z "$db_type" ]; then - colorized_echo red "Could not determine database type from backup." - colorized_echo yellow "SQLALCHEMY_DATABASE_URL: ${SQLALCHEMY_DATABASE_URL:-not set}" - rm -rf "$temp_restore_dir" - exit 1 - fi - - colorized_echo green "āœ“ Database configuration detected: $db_type" - - # Confirm restore - colorized_echo red "āš ļø DANGER: This will PERMANENTLY overwrite your current $db_type database!" - colorized_echo yellow "WARNING: This will overwrite your current $db_type database!" - colorized_echo blue "Database type: $db_type" - if [ -n "$db_name" ]; then - colorized_echo blue "Database name: $db_name" - fi - if [ -n "$container_name" ]; then - colorized_echo blue "Container: $container_name" - fi - - while true; do - printf "Do you want to proceed with the restore? (yes/no): " - read -r confirm - if [[ "$confirm" =~ ^[Yy](es)?$ ]]; then - break - elif [[ "$confirm" =~ ^[Nn](o)?$ ]]; then - colorized_echo yellow "Restore cancelled." - rm -rf "$temp_restore_dir" - exit 0 - else - colorized_echo red "Please answer yes or no." - fi - done - - # Stop pasarguard services before restore for clean state - colorized_echo blue "Stopping pasarguard services for clean restore..." - if [[ "$db_type" == "sqlite" ]]; then - # For SQLite, stop all services since we need to restore files - down_pasarguard - else - # For containerized databases, stop only application services - # Keep database containers running for restore via docker exec - stop_pasarguard_app_services - fi - - # Perform restore - colorized_echo red "āš ļø DANGER: Starting database restore - this will overwrite existing data!" - colorized_echo blue "Starting database restore..." - - case $db_type in - sqlite) - if [ ! -f "$temp_restore_dir/db_backup.sqlite" ]; then - colorized_echo red "SQLite backup file not found in backup archive." - rm -rf "$temp_restore_dir" - exit 1 - fi - - if [ -f "$sqlite_file" ]; then - cp "$sqlite_file" "${sqlite_file}.backup.$(date +%Y%m%d%H%M%S)" 2>>"$log_file" - fi - - if cp "$temp_restore_dir/db_backup.sqlite" "$sqlite_file" 2>>"$log_file"; then - colorized_echo green "SQLite database restored successfully." - else - colorized_echo red "Failed to restore SQLite database." - echo "SQLite restore failed" >>"$log_file" - rm -rf "$temp_restore_dir" - exit 1 - fi - ;; - - mariadb|mysql) - if [ ! -f "$temp_restore_dir/db_backup.sql" ]; then - colorized_echo red "Database backup file not found in backup archive." - rm -rf "$temp_restore_dir" - exit 1 - fi - - if [[ "$db_host" == "127.0.0.1" || "$db_host" == "localhost" || "$db_host" == "::1" ]]; then - if [ -z "$container_name" ]; then - colorized_echo red "Error: MySQL/MariaDB container not found. Is the container running?" - echo "MySQL/MariaDB container not found. Container name: ${container_name:-empty}" >>"$log_file" - rm -rf "$temp_restore_dir" - exit 1 - else - local verified_container=$(verify_and_start_container "$container_name" "$db_type") - if [ -z "$verified_container" ]; then - colorized_echo red "Failed to start database container. Please start it manually." - rm -rf "$temp_restore_dir" - exit 1 - fi - container_name="$verified_container" - - # Check if this is actually a MariaDB container - local is_mariadb=false - local mysql_cmd="mysql" - local db_type_name="MySQL" - if docker exec "$container_name" mariadb --version >/dev/null 2>&1; then - is_mariadb=true - mysql_cmd="mariadb" - db_type_name="MariaDB" - fi - - colorized_echo blue "Restoring $db_type_name database from container: $container_name" - - local restore_success=false - local backup_restore_user="${db_user:-${DB_USER:-}}" - local backup_restore_password="${db_password:-${DB_PASSWORD:-}}" - local app_db_target="${db_name:-${current_db_name:-}}" - - # Try root password from backup .env first - if [ -n "${MYSQL_ROOT_PASSWORD:-}" ]; then - colorized_echo blue "Trying root user from backup .env..." - if docker exec -i "$container_name" "$mysql_cmd" -u root -p"$MYSQL_ROOT_PASSWORD" < "$temp_restore_dir/db_backup.sql" 2>>"$log_file"; then - restore_success=true - colorized_echo green "$db_type_name database restored successfully." - else - colorized_echo yellow "Root restore failed with backup .env credentials, trying fallback..." - echo "$db_type_name restore failed with backup MYSQL_ROOT_PASSWORD" >>"$log_file" - fi - fi - - # If root password changed after backup, try current installation value - if [ "$restore_success" = false ] && [ -n "$current_mysql_root_password" ] && [ "$current_mysql_root_password" != "${MYSQL_ROOT_PASSWORD:-}" ]; then - colorized_echo blue "Trying root user from current installation .env..." - if docker exec -i "$container_name" "$mysql_cmd" -u root -p"$current_mysql_root_password" < "$temp_restore_dir/db_backup.sql" 2>>"$log_file"; then - restore_success=true - colorized_echo green "$db_type_name database restored successfully." - else - colorized_echo yellow "Root restore failed with current .env credentials, trying app user fallback..." - echo "$db_type_name restore failed with current MYSQL_ROOT_PASSWORD" >>"$log_file" - fi - fi - - # Try app user from backup SQL URL/.env - if [ "$restore_success" = false ] && [ -n "$backup_restore_user" ] && [ -n "$backup_restore_password" ]; then - colorized_echo blue "Trying app user '$backup_restore_user' from backup credentials..." - if [ -n "$app_db_target" ]; then - if docker exec -i "$container_name" "$mysql_cmd" -u "$backup_restore_user" -p"$backup_restore_password" "$app_db_target" < "$temp_restore_dir/db_backup.sql" 2>>"$log_file"; then - restore_success=true - colorized_echo green "$db_type_name database restored successfully." - fi - fi - if [ "$restore_success" = false ] && docker exec -i "$container_name" "$mysql_cmd" -u "$backup_restore_user" -p"$backup_restore_password" < "$temp_restore_dir/db_backup.sql" 2>>"$log_file"; then - restore_success=true - colorized_echo green "$db_type_name database restored successfully." - elif [ "$restore_success" = false ]; then - colorized_echo yellow "App user restore failed with backup credentials, trying current installation credentials..." - echo "$db_type_name restore failed with backup app credentials" >>"$log_file" - fi - fi - - # Final fallback: current installation app credentials - if [ "$restore_success" = false ] && [ -n "$current_db_user" ] && [ -n "$current_db_password" ] && { [ "$current_db_user" != "$backup_restore_user" ] || [ "$current_db_password" != "$backup_restore_password" ]; }; then - colorized_echo blue "Trying app user '$current_db_user' from current installation .env..." - if [ -n "$app_db_target" ]; then - if docker exec -i "$container_name" "$mysql_cmd" -u "$current_db_user" -p"$current_db_password" "$app_db_target" < "$temp_restore_dir/db_backup.sql" 2>>"$log_file"; then - restore_success=true - colorized_echo green "$db_type_name database restored successfully." - fi - fi - if [ "$restore_success" = false ] && docker exec -i "$container_name" "$mysql_cmd" -u "$current_db_user" -p"$current_db_password" < "$temp_restore_dir/db_backup.sql" 2>>"$log_file"; then - restore_success=true - colorized_echo green "$db_type_name database restored successfully." - elif [ "$restore_success" = false ]; then - echo "$db_type_name restore failed with current app credentials" >>"$log_file" - fi - fi - - if [ "$restore_success" = false ]; then - colorized_echo red "Failed to restore $db_type_name database with all available credentials." - colorized_echo yellow "Check log file for details: $log_file" - rm -rf "$temp_restore_dir" - exit 1 - fi - fi - else - colorized_echo red "Remote $db_type restore not supported yet." - rm -rf "$temp_restore_dir" - exit 1 - fi - ;; - - postgresql|timescaledb) - if [ ! -f "$temp_restore_dir/db_backup.sql" ]; then - colorized_echo red "Database backup file not found in backup archive." - rm -rf "$temp_restore_dir" - exit 1 - fi - - # Verify backup file is not empty and is readable - if [ ! -s "$temp_restore_dir/db_backup.sql" ]; then - colorized_echo red "Database backup file is empty or unreadable." - rm -rf "$temp_restore_dir" - exit 1 - fi - - local backup_size=$(du -h "$temp_restore_dir/db_backup.sql" | cut -f1) - colorized_echo blue "Backup file size: $backup_size" - - if [[ "$db_host" == "127.0.0.1" || "$db_host" == "localhost" || "$db_host" == "::1" ]] && [ -n "$container_name" ]; then - local verified_container=$(verify_and_start_container "$container_name" "$db_type") - if [ -z "$verified_container" ]; then - colorized_echo red "Failed to start database container. Please start it manually." - rm -rf "$temp_restore_dir" - exit 1 - fi - container_name="$verified_container" - - colorized_echo blue "Restoring $db_type database from container: $container_name" - - # Prepare restore credentials - local restore_user="${db_user:-${DB_USER:-postgres}}" - local restore_password="${db_password:-${DB_PASSWORD:-}}" - - if [ -z "$restore_password" ]; then - colorized_echo red "No database password found for restore." - rm -rf "$temp_restore_dir" - exit 1 - fi - - export PGPASSWORD="$restore_password" - local restore_success=false - - if [ "$db_type" = "timescaledb" ]; then - # TimescaleDB requires special restore procedure to handle version mismatches. - # A plain psql restore fails when the backup was taken with a different - # TimescaleDB version because DROP EXTENSION / CREATE EXTENSION cycles - # break when the shared library is already loaded with the new version. - # The fix: drop & recreate the database, then use the official - # timescaledb_pre_restore() / timescaledb_post_restore() wrapper. - # See: https://docs.timescale.com/self-hosted/latest/backup-and-restore/ - colorized_echo blue "Using TimescaleDB-safe restore procedure..." - - # Use target installation's identity when available, falling back to backup values. - # This ensures cross-server restores work correctly when the local DB user/name - # differs from the backup source. - local target_db_name="${current_db_name:-$db_name}" - local target_db_owner="${current_db_user:-$restore_user}" - - # Drop and recreate the target database for a clean slate - colorized_echo blue "Dropping and recreating database '$target_db_name'..." - docker exec "$container_name" psql -U postgres -d postgres \ - -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '$target_db_name' AND pid <> pg_backend_pid();" \ - >>"$log_file" 2>&1 - docker exec "$container_name" psql -U postgres -d postgres \ - -c "DROP DATABASE IF EXISTS \"$target_db_name\";" >>"$log_file" 2>&1 - docker exec "$container_name" psql -U postgres -d postgres \ - -c "CREATE DATABASE \"$target_db_name\" OWNER \"$target_db_owner\";" >>"$log_file" 2>&1 - - # Create the timescaledb extension in the fresh database - docker exec "$container_name" psql -U postgres -d "$target_db_name" \ - -c "CREATE EXTENSION IF NOT EXISTS timescaledb;" >>"$log_file" 2>&1 - - # Call pre_restore to put TimescaleDB into restore mode - colorized_echo blue "Calling timescaledb_pre_restore()..." - docker exec "$container_name" psql -U postgres -d "$target_db_name" \ - -c "SELECT timescaledb_pre_restore();" >>"$log_file" 2>&1 - - # Filter out extension DROP/CREATE statements from the dump. - # pg_dump --clean --if-exists generates DROP EXTENSION / CREATE EXTENSION - # lines that would undo the pre_restore() setup above. - colorized_echo blue "Preparing dump (filtering extension statements)..." - grep -v -E '^\s*(DROP|CREATE)\s+EXTENSION\s+(IF\s+(EXISTS|NOT\s+EXISTS)\s+)?timescaledb\b' \ - "$temp_restore_dir/db_backup.sql" > "$temp_restore_dir/db_backup_filtered.sql" 2>>"$log_file" - - # Restore the filtered dump with ON_ERROR_STOP so psql exits non-zero on SQL errors - colorized_echo blue "Restoring database dump..." - if docker exec -i "$container_name" psql -v ON_ERROR_STOP=1 -U "$restore_user" -d "$target_db_name" < "$temp_restore_dir/db_backup_filtered.sql" 2>>"$log_file"; then - restore_success=true - else - # Fallback: try with postgres superuser - colorized_echo yellow "Trying with postgres superuser..." - if docker exec -i "$container_name" psql -v ON_ERROR_STOP=1 -U postgres -d "$target_db_name" < "$temp_restore_dir/db_backup_filtered.sql" 2>>"$log_file"; then - restore_success=true - fi - fi - - # Clean up filtered dump - rm -f "$temp_restore_dir/db_backup_filtered.sql" - - # Call post_restore regardless of outcome to leave DB in a usable state - colorized_echo blue "Calling timescaledb_post_restore()..." - docker exec "$container_name" psql -U postgres -d "$target_db_name" \ - -c "SELECT timescaledb_post_restore();" >>"$log_file" 2>&1 - - if [ "$restore_success" = true ]; then - colorized_echo green "TimescaleDB database restored successfully." - fi - else - # Plain PostgreSQL restore with ON_ERROR_STOP so psql exits non-zero on SQL errors - colorized_echo blue "Attempting restore using app user '$restore_user' to database '$db_name'..." - if docker exec -i "$container_name" psql -v ON_ERROR_STOP=1 -U "$restore_user" -d "$db_name" < "$temp_restore_dir/db_backup.sql" 2>>"$log_file"; then - colorized_echo green "$db_type database restored successfully." - restore_success=true - else - # If that fails, try using postgres superuser - colorized_echo yellow "Trying with postgres superuser..." - if docker exec -i "$container_name" psql -v ON_ERROR_STOP=1 -U postgres -d "$db_name" < "$temp_restore_dir/db_backup.sql" 2>>"$log_file"; then - colorized_echo green "$db_type database restored successfully." - restore_success=true - else - # Try restoring to postgres database (for pg_dumpall backups) - if docker exec -i "$container_name" psql -v ON_ERROR_STOP=1 -U postgres -d postgres < "$temp_restore_dir/db_backup.sql" 2>>"$log_file"; then - colorized_echo green "$db_type database restored successfully." - restore_success=true - fi - fi - fi - fi - - unset PGPASSWORD - - if [ "$restore_success" = false ]; then - colorized_echo red "Failed to restore $db_type database." - colorized_echo yellow "Check log file for details: $log_file" - rm -rf "$temp_restore_dir" - exit 1 - fi - else - colorized_echo red "Remote $db_type restore not supported yet." - rm -rf "$temp_restore_dir" - exit 1 - fi - ;; - *) - colorized_echo red "Unsupported database type: $db_type" - rm -rf "$temp_restore_dir" - exit 1 - ;; - esac - - # Restore data directory if included in backup - colorized_echo blue "Restoring data directory..." - local extracted_data_dir="$temp_restore_dir/pasarguard_data" - if [ -d "$extracted_data_dir" ]; then - if ! command -v rsync >/dev/null 2>&1; then - detect_os - install_package rsync - fi - mkdir -p "$DATA_DIR" - if ! rsync -a "$extracted_data_dir/" "$DATA_DIR/" 2>>"$log_file"; then - colorized_echo red "Failed to restore data directory." - echo "Failed to restore data directory from $extracted_data_dir to $DATA_DIR" >>"$log_file" - rm -rf "$temp_restore_dir" - exit 1 - fi - colorized_echo green "Data directory restored to $DATA_DIR." - else - colorized_echo yellow "No pasarguard_data directory found in backup. Skipping data restore." - fi - - # Restore configuration files if needed - colorized_echo blue "Restoring configuration files..." - if [ -f "$temp_restore_dir/.env" ]; then - cp "$temp_restore_dir/.env" "$APP_DIR/.env.backup.$(date +%Y%m%d%H%M%S)" 2>>"$log_file" - cp "$temp_restore_dir/.env" "$APP_DIR/.env" 2>>"$log_file" - colorized_echo green "Environment file restored." - local preserve_db_credentials=false - if [[ "$db_type" != "sqlite" ]]; then - if [ -n "$current_db_user" ] && [ -n "${DB_USER:-}" ] && [ "$current_db_user" != "$DB_USER" ]; then - preserve_db_credentials=true - elif [ -n "$current_db_name" ] && [ -n "${DB_NAME:-}" ] && [ "$current_db_name" != "$DB_NAME" ]; then - preserve_db_credentials=true - elif [ -n "$current_db_password" ] && [ -n "${DB_PASSWORD:-}" ] && [ "$current_db_password" != "$DB_PASSWORD" ]; then - preserve_db_credentials=true - fi - fi - if [ "$preserve_db_credentials" = true ]; then - colorized_echo yellow "Database credentials in backup differ from current installation; preserving current database credentials." - if [ -n "$current_db_user" ]; then - replace_or_append_env_var "DB_USER" "$current_db_user" false "$ENV_FILE" - fi - if [ -n "$current_db_name" ]; then - replace_or_append_env_var "DB_NAME" "$current_db_name" false "$ENV_FILE" - fi - if [ -n "$current_db_password" ]; then - replace_or_append_env_var "DB_PASSWORD" "$current_db_password" false "$ENV_FILE" - fi - if [ -n "$current_sqlalchemy_url" ]; then - replace_or_append_env_var "SQLALCHEMY_DATABASE_URL" "$current_sqlalchemy_url" true "$ENV_FILE" - fi - fi - fi + if (service != "" && is_backend) { + print service + } + } + ' | head -n 1) - if [ -f "$temp_restore_dir/docker-compose.yml" ]; then - cp "$temp_restore_dir/docker-compose.yml" "$APP_DIR/docker-compose.yml.backup.$(date +%Y%m%d%H%M%S)" 2>>"$log_file" - cp "$temp_restore_dir/docker-compose.yml" "$APP_DIR/docker-compose.yml" 2>>"$log_file" - colorized_echo green "Docker Compose file restored." + if [ -n "$service_name" ]; then + echo "$service_name" + return 0 fi - # Clean up - rm -rf "$temp_restore_dir" - - # Restart pasarguard services - colorized_echo blue "Restarting pasarguard services..." - if [[ "$db_type" == "sqlite" ]]; then - # For SQLite, restart all services - up_pasarguard - else - # For containerized databases, restart only application services - start_pasarguard_app_services + service_name=$(list_pasarguard_app_services | head -n 1) + if [ -n "$service_name" ]; then + echo "$service_name" + return 0 fi - colorized_echo green "Restore completed successfully!" - colorized_echo green "PasarGuard services have been restarted." + return 1 } -backup_command() { - colorized_echo blue "Starting backup process..." - - # Check if pasarguard is installed - if ! is_pasarguard_installed; then - colorized_echo red "pasarguard is not installed!" - return 1 - fi - - local backup_dir="$APP_DIR/backup" - local temp_dir="/tmp/pasarguard_backup" - local timestamp=$(date +"%Y%m%d%H%M%S") - local backup_file="$backup_dir/backup_$timestamp.zip" - local error_messages=() - local log_file="/var/log/pasarguard_backup_error.log" - local final_backup_paths=() - local split_size_arg="47m" # keep Telegram chunks under 50MB - >"$log_file" - echo "Backup Log - $(date)" >>"$log_file" - - colorized_echo blue "Reading environment configuration..." - - if ! command -v rsync >/dev/null 2>&1; then - detect_os - install_package rsync - fi - - if ! command -v zip >/dev/null 2>&1; then - detect_os - install_package zip - fi - - # Remove old backups before creating new one (keep only latest) - rm -f "$backup_dir"/backup_*.tar.gz - rm -f "$backup_dir"/backup_*.zip - rm -f "$backup_dir"/backup_*.z[0-9][0-9] 2>/dev/null || true - mkdir -p "$backup_dir" - - # Clean up temp directory completely before starting - rm -rf "$temp_dir" - mkdir -p "$temp_dir" +stop_pasarguard_app_services() { + local services + services=$(list_pasarguard_app_services | xargs) + [ -z "$services" ] && services="pasarguard" + $COMPOSE -f "$COMPOSE_FILE" -p "$APP_NAME" stop $services 2>/dev/null || true +} - if [ -f "$ENV_FILE" ]; then - while IFS='=' read -r key value; do - if [[ -z "$key" || "$key" =~ ^# ]]; then - continue - fi - key=$(echo "$key" | xargs) - value=$(echo "$value" | xargs) - # Remove surrounding quotes from value if present - value=$(echo "$value" | sed -E 's/^["'\''](.*)["'\'']$/\1/') - if [[ "$key" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]]; then - export "$key"="$value" - else - echo "Skipping invalid line in .env: $key=$value" >>"$log_file" - fi - done <"$ENV_FILE" - else - error_messages+=("Environment file (.env) not found.") - echo "Environment file (.env) not found." >>"$log_file" - send_backup_error_to_telegram "${error_messages[*]}" "$log_file" - exit 1 - fi +start_pasarguard_app_services() { + local services + services=$(list_pasarguard_app_services | xargs) + [ -z "$services" ] && services="pasarguard" + $COMPOSE -f "$COMPOSE_FILE" -p "$APP_NAME" start $services 2>/dev/null || true +} - local db_type="" - local sqlite_file="" - local db_host="" - local db_port="" - local db_user="" - local db_password="" - local db_name="" +find_container() { + local db_type=$1 local container_name="" + detect_compose - # SQLALCHEMY_DATABASE_URL should already be loaded from .env above - # Just log what we have - echo "SQLALCHEMY_DATABASE_URL from environment: ${SQLALCHEMY_DATABASE_URL:-not set}" >>"$log_file" - - if [ -z "$SQLALCHEMY_DATABASE_URL" ]; then - colorized_echo red "Error: SQLALCHEMY_DATABASE_URL not found in .env file or not set" - echo "Please check $ENV_FILE for SQLALCHEMY_DATABASE_URL" >>"$log_file" - error_messages+=("SQLALCHEMY_DATABASE_URL not found in .env file") - colorized_echo yellow "Please check the log file for details: $log_file" - return 1 - fi - - if [ -n "$SQLALCHEMY_DATABASE_URL" ]; then - echo "Parsing SQLALCHEMY_DATABASE_URL: ${SQLALCHEMY_DATABASE_URL%%@*}" >>"$log_file" - - # Extract database type from scheme - if [[ "$SQLALCHEMY_DATABASE_URL" =~ ^sqlite ]]; then - db_type="sqlite" - # Extract SQLite file path - # SQLite URLs: sqlite:///relative/path or sqlite:////absolute/path - local sqlite_url_part="${SQLALCHEMY_DATABASE_URL#*://}" - sqlite_url_part="${sqlite_url_part%%\?*}" - sqlite_url_part="${sqlite_url_part%%#*}" - - # SQLite URL format: - # sqlite:////absolute/path (4 slashes = absolute path /path) - # After removing 'sqlite://', //absolute/path remains, convert to /absolute/path - if [[ "$sqlite_url_part" =~ ^//(.*)$ ]]; then - # Absolute path: sqlite:////absolute/path -> /absolute/path - sqlite_file="/${BASH_REMATCH[1]}" - elif [[ "$sqlite_url_part" =~ ^/(.*)$ ]]; then - # Could be absolute (sqlite:///path) or relative depending on context - # In practice, treat as absolute since SQLAlchemy uses 4 slashes for absolute - sqlite_file="/${BASH_REMATCH[1]}" - else - # Relative path (no leading slash) - sqlite_file="$sqlite_url_part" - fi - elif [[ "$SQLALCHEMY_DATABASE_URL" =~ ^(mysql|mariadb|postgresql)[^:]*:// ]]; then - # Extract scheme to determine type - if [[ "$SQLALCHEMY_DATABASE_URL" =~ ^mariadb[^:]*:// ]]; then - db_type="mariadb" - elif [[ "$SQLALCHEMY_DATABASE_URL" =~ ^mysql[^:]*:// ]]; then - db_type="mysql" - elif [[ "$SQLALCHEMY_DATABASE_URL" =~ ^postgresql[^:]*:// ]]; then - # Check if it's timescaledb by checking for specific patterns or container - if grep -q "image: timescale/timescaledb" "$COMPOSE_FILE" 2>/dev/null; then - db_type="timescaledb" - else - db_type="postgresql" - fi - fi - - # Parse connection string: scheme://[user[:password]@]host[:port]/database[?query] - # Remove scheme prefix - local url_part="${SQLALCHEMY_DATABASE_URL#*://}" - # Remove query parameters if present - url_part="${url_part%%\?*}" - url_part="${url_part%%#*}" - - # Extract auth part (user:password@) - if [[ "$url_part" =~ ^([^@]+)@(.+)$ ]]; then - local auth_part="${BASH_REMATCH[1]}" - url_part="${BASH_REMATCH[2]}" - - # Extract username and password - if [[ "$auth_part" =~ ^([^:]+):(.+)$ ]]; then - db_user="${BASH_REMATCH[1]}" - db_password="${BASH_REMATCH[2]}" - else - db_user="$auth_part" - fi - fi - - # Extract host, port, and database - if [[ "$url_part" =~ ^([^:/]+)(:([0-9]+))?/(.+)$ ]]; then - db_host="${BASH_REMATCH[1]}" - db_port="${BASH_REMATCH[3]:-}" - db_name="${BASH_REMATCH[4]}" - - # Remove query parameters from database name if any - db_name="${db_name%%\?*}" - db_name="${db_name%%#*}" - - # Set default ports if not specified - if [ -z "$db_port" ]; then - if [[ "$db_type" =~ ^(mysql|mariadb)$ ]]; then - db_port="3306" - elif [[ "$db_type" =~ ^(postgresql|timescaledb)$ ]]; then - db_port="5432" - fi - fi - fi + case $db_type in + mariadb) + container_name=$($COMPOSE -f "$COMPOSE_FILE" -p "$APP_NAME" ps -q mariadb 2>/dev/null || true) + [ -z "$container_name" ] && container_name=$($COMPOSE -f "$COMPOSE_FILE" -p "$APP_NAME" ps --format json mariadb 2>/dev/null | jq -r '.Name' 2>/dev/null | head -n 1 || true) + [ -z "$container_name" ] && container_name=$(docker ps --filter "name=${APP_NAME}" --filter "name=mariadb" --format '{{.ID}}' 2>/dev/null | head -n 1 || true) + [ -z "$container_name" ] && container_name="mariadb" + ;; + mysql) + container_name=$($COMPOSE -f "$COMPOSE_FILE" -p "$APP_NAME" ps -q mysql 2>/dev/null || true) + [ -z "$container_name" ] && container_name=$($COMPOSE -f "$COMPOSE_FILE" -p "$APP_NAME" ps -q mariadb 2>/dev/null || true) + [ -z "$container_name" ] && container_name=$($COMPOSE -f "$COMPOSE_FILE" -p "$APP_NAME" ps --format json mysql mariadb 2>/dev/null | jq -r 'if type == "array" then .[] else . end | .Name' 2>/dev/null | head -n 1 || true) + [ -z "$container_name" ] && container_name=$(docker ps --filter "name=${APP_NAME}" --filter "name=mysql" --format '{{.ID}}' 2>/dev/null | head -n 1 || true) + [ -z "$container_name" ] && container_name=$(docker ps --filter "name=${APP_NAME}" --filter "name=mariadb" --format '{{.ID}}' 2>/dev/null | head -n 1 || true) + [ -z "$container_name" ] && container_name="mysql" + ;; + postgresql|timescaledb) + container_name=$($COMPOSE -f "$COMPOSE_FILE" -p "$APP_NAME" ps -q timescaledb 2>/dev/null || true) + [ -z "$container_name" ] && container_name=$($COMPOSE -f "$COMPOSE_FILE" -p "$APP_NAME" ps -q postgresql 2>/dev/null || true) + [ -z "$container_name" ] && container_name=$($COMPOSE -f "$COMPOSE_FILE" -p "$APP_NAME" ps --format json timescaledb postgresql 2>/dev/null | jq -r 'if type == "array" then .[] else . end | .Name' 2>/dev/null | head -n 1 || true) + [ -z "$container_name" ] && container_name="${APP_NAME}-timescaledb-1" + ;; + esac + echo "$container_name" +} - # For local databases, try to find container name from docker-compose - if [[ "$db_host" == "127.0.0.1" || "$db_host" == "localhost" || "$db_host" == "::1" ]]; then - container_name=$(find_container "$db_type") - echo "Container name/ID for $db_type: $container_name" >>"$log_file" - fi - fi - fi +check_container() { + local container_name=$1 + local db_type=$2 + local actual_container="" - if [ -n "$db_type" ]; then - echo "Database detected: $db_type" >>"$log_file" - echo "Database host: ${db_host:-localhost}" >>"$log_file" - colorized_echo blue "Database detected: $db_type" - colorized_echo blue "Backing up database..." + if docker inspect "$container_name" >/dev/null 2>&1; then + actual_container="$container_name" + else case $db_type in mariadb) - if [[ "$db_host" == "127.0.0.1" || "$db_host" == "localhost" || "$db_host" == "::1" ]]; then - if [ -z "$container_name" ]; then - colorized_echo red "Error: MariaDB container not found. Is the container running?" - echo "MariaDB container not found. Container name: ${container_name:-empty}" >>"$log_file" - error_messages+=("MariaDB container not found or not running.") - else - local verified_container=$(check_container "$container_name" "$db_type") - if [ -z "$verified_container" ]; then - colorized_echo red "Error: MariaDB container not found or not running." - echo "Container not found or not running: $container_name" >>"$log_file" - error_messages+=("MariaDB container not found or not running.") - else - container_name="$verified_container" - # Local Docker container - # Try root user with MYSQL_ROOT_PASSWORD first for all databases backup - if [ -n "${MYSQL_ROOT_PASSWORD:-}" ]; then - colorized_echo blue "Backing up all MariaDB databases from container: $container_name (using root user)" - if docker exec "$container_name" mariadb-dump -u root -p"$MYSQL_ROOT_PASSWORD" --all-databases --ignore-database=mysql --ignore-database=performance_schema --ignore-database=information_schema --ignore-database=sys --events --triggers >"$temp_dir/db_backup.sql" 2>>"$log_file"; then - colorized_echo green "MariaDB backup completed successfully (all databases)" - else - # Fallback to SQL URL credentials for specific database - colorized_echo yellow "Root backup failed, falling back to app user for specific database" - local backup_user="${db_user:-${DB_USER:-}}" - local backup_password="${db_password:-${DB_PASSWORD:-}}" - - if [ -z "$backup_password" ] || [ -z "$db_name" ]; then - colorized_echo red "Error: Cannot fallback - missing database name or password in SQLALCHEMY_DATABASE_URL" - error_messages+=("MariaDB backup failed - root backup failed and fallback credentials incomplete.") - else - colorized_echo blue "Backing up MariaDB database '$db_name' from container: $container_name (using app user)" - if ! docker exec "$container_name" mariadb-dump -u "$backup_user" -p"$backup_password" "$db_name" --events --triggers >"$temp_dir/db_backup.sql" 2>>"$log_file"; then - colorized_echo red "MariaDB dump failed. Check log file for details." - error_messages+=("MariaDB dump failed.") - else - colorized_echo green "MariaDB backup completed successfully" - fi - fi - fi - else - # No MYSQL_ROOT_PASSWORD, use SQL URL credentials for specific database - local backup_user="${db_user:-${DB_USER:-}}" - local backup_password="${db_password:-${DB_PASSWORD:-}}" - - if [ -z "$backup_password" ]; then - colorized_echo red "Error: Database password not found. Check MYSQL_ROOT_PASSWORD or SQLALCHEMY_DATABASE_URL in .env" - error_messages+=("MariaDB password not found.") - elif [ -z "$db_name" ]; then - colorized_echo red "Error: Database name not found in SQLALCHEMY_DATABASE_URL" - error_messages+=("MariaDB database name not found.") - else - colorized_echo blue "Backing up MariaDB database '$db_name' from container: $container_name (using app user)" - if ! docker exec "$container_name" mariadb-dump -u "$backup_user" -p"$backup_password" "$db_name" --events --triggers >"$temp_dir/db_backup.sql" 2>>"$log_file"; then - colorized_echo red "MariaDB dump failed. Check log file for details." - error_messages+=("MariaDB dump failed.") - else - colorized_echo green "MariaDB backup completed successfully" - fi - fi - fi - fi - fi - else - # Remote database - would need mariadb-client installed - colorized_echo red "Remote MariaDB backup not yet supported. Please use local database or install mariadb-client." - error_messages+=("Remote MariaDB backup not yet supported. Please use local database or install mariadb-client.") - fi + actual_container=$($COMPOSE -f "$COMPOSE_FILE" -p "$APP_NAME" ps -q mariadb 2>/dev/null || true) + [ -z "$actual_container" ] && [ -f "$COMPOSE_FILE" ] && actual_container="${APP_NAME}-mariadb-1" ;; mysql) - if [[ "$db_host" == "127.0.0.1" || "$db_host" == "localhost" || "$db_host" == "::1" ]]; then - if [ -z "$container_name" ]; then - colorized_echo red "Error: MySQL container not found. Is the container running?" - echo "MySQL container not found. Container name: ${container_name:-empty}" >>"$log_file" - error_messages+=("MySQL container not found or not running.") - else - local verified_container=$(check_container "$container_name" "$db_type") - if [ -z "$verified_container" ]; then - colorized_echo red "Error: MySQL/MariaDB container not found or not running." - echo "Container not found or not running: $container_name" >>"$log_file" - error_messages+=("MySQL/MariaDB container not found or not running.") - else - container_name="$verified_container" - # Check if this is actually a MariaDB container (try mariadb-dump first) - local is_mariadb=false - if docker exec "$container_name" mariadb-dump --version >/dev/null 2>&1; then - is_mariadb=true - fi - - # Local Docker container - # Try root user with MYSQL_ROOT_PASSWORD first for all databases backup - if [ -n "${MYSQL_ROOT_PASSWORD:-}" ]; then - # Choose command based on whether it's MariaDB or MySQL - local mysql_cmd="mysql" - local dump_cmd="mysqldump" - local db_type_name="MySQL" - if [ "$is_mariadb" = true ]; then - mysql_cmd="mariadb" - dump_cmd="mariadb-dump" - db_type_name="MariaDB" - fi - - colorized_echo blue "Backing up all $db_type_name databases from container: $container_name (using root user)" - databases=$(docker exec "$container_name" "$mysql_cmd" -u root -p"$MYSQL_ROOT_PASSWORD" -e "SHOW DATABASES;" 2>>"$log_file" | grep -Ev "^(Database|mysql|performance_schema|information_schema|sys)$" || true) - if [ -z "$databases" ]; then - colorized_echo yellow "No user databases found, falling back to specific database backup" - # Fallback to SQL URL credentials - local backup_user="${db_user:-${DB_USER:-}}" - local backup_password="${db_password:-${DB_PASSWORD:-}}" - - if [ -z "$backup_password" ] || [ -z "$db_name" ]; then - colorized_echo red "Error: Cannot fallback - missing database name or password in SQLALCHEMY_DATABASE_URL" - error_messages+=("MySQL backup failed - no databases found and fallback credentials incomplete.") - else - colorized_echo blue "Backing up $db_type_name database '$db_name' from container: $container_name (using app user)" - if ! docker exec "$container_name" "$dump_cmd" -u "$backup_user" -p"$backup_password" "$db_name" --events --triggers >"$temp_dir/db_backup.sql" 2>>"$log_file"; then - colorized_echo red "$db_type_name dump failed. Check log file for details." - error_messages+=("$db_type_name dump failed.") - else - colorized_echo green "$db_type_name backup completed successfully" - fi - fi - elif ! docker exec "$container_name" "$dump_cmd" -u root -p"$MYSQL_ROOT_PASSWORD" --databases $databases --events --triggers >"$temp_dir/db_backup.sql" 2>>"$log_file"; then - # Root backup failed, fallback to SQL URL credentials - colorized_echo yellow "Root backup failed, falling back to app user for specific database" - local backup_user="${db_user:-${DB_USER:-}}" - local backup_password="${db_password:-${DB_PASSWORD:-}}" - - if [ -z "$backup_password" ] || [ -z "$db_name" ]; then - colorized_echo red "Error: Cannot fallback - missing database name or password in SQLALCHEMY_DATABASE_URL" - error_messages+=("MySQL backup failed - root backup failed and fallback credentials incomplete.") - else - colorized_echo blue "Backing up $db_type_name database '$db_name' from container: $container_name (using app user)" - if ! docker exec "$container_name" "$dump_cmd" -u "$backup_user" -p"$backup_password" "$db_name" --events --triggers >"$temp_dir/db_backup.sql" 2>>"$log_file"; then - colorized_echo red "$db_type_name dump failed. Check log file for details." - error_messages+=("$db_type_name dump failed.") - else - colorized_echo green "$db_type_name backup completed successfully" - fi - fi - else - colorized_echo green "$db_type_name backup completed successfully (all databases)" - fi - else - # No MYSQL_ROOT_PASSWORD, use SQL URL credentials for specific database - local backup_user="${db_user:-${DB_USER:-}}" - local backup_password="${db_password:-${DB_PASSWORD:-}}" - local dump_cmd="mysqldump" - local db_type_name="MySQL" - if [ "$is_mariadb" = true ]; then - dump_cmd="mariadb-dump" - db_type_name="MariaDB" - fi - - if [ -z "$backup_password" ]; then - colorized_echo red "Error: Database password not found. Check MYSQL_ROOT_PASSWORD or SQLALCHEMY_DATABASE_URL in .env" - error_messages+=("MySQL password not found.") - elif [ -z "$db_name" ]; then - colorized_echo red "Error: Database name not found in SQLALCHEMY_DATABASE_URL" - error_messages+=("MySQL database name not found.") - else - colorized_echo blue "Backing up $db_type_name database '$db_name' from container: $container_name (using app user)" - if ! docker exec "$container_name" "$dump_cmd" -u "$backup_user" -p"$backup_password" "$db_name" --events --triggers >"$temp_dir/db_backup.sql" 2>>"$log_file"; then - colorized_echo red "$db_type_name dump failed. Check log file for details." - error_messages+=("$db_type_name dump failed.") - else - colorized_echo green "$db_type_name backup completed successfully" - fi - fi - fi - fi - fi - else - # Remote database - would need mysql-client installed - colorized_echo red "Remote MySQL backup not yet supported. Please use local database or install mysql-client." - error_messages+=("Remote MySQL backup not yet supported. Please use local database or install mysql-client.") - fi - ;; - postgresql) - if [[ "$db_host" == "127.0.0.1" || "$db_host" == "localhost" || "$db_host" == "::1" ]]; then - if [ -z "$container_name" ]; then - colorized_echo red "Error: PostgreSQL container not found. Is the container running?" - echo "PostgreSQL container not found. Container name: ${container_name:-empty}" >>"$log_file" - error_messages+=("PostgreSQL container not found or not running.") - else - local verified_container=$(check_container "$container_name" "$db_type") - if [ -z "$verified_container" ]; then - colorized_echo red "Error: PostgreSQL container not found or not running." - echo "Container not found or not running: $container_name" >>"$log_file" - error_messages+=("PostgreSQL container not found or not running.") - else - container_name="$verified_container" - # Local Docker container - # Try postgres superuser with DB_PASSWORD first for pg_dumpall (all databases) - if [ -n "${DB_PASSWORD:-}" ]; then - colorized_echo blue "Backing up all PostgreSQL databases from container: $container_name (using postgres superuser)" - export PGPASSWORD="$DB_PASSWORD" - if docker exec "$container_name" pg_dumpall -U postgres >"$temp_dir/db_backup.sql" 2>>"$log_file"; then - colorized_echo green "PostgreSQL backup completed successfully (all databases)" - unset PGPASSWORD - else - # Fallback to pg_dump with SQL URL credentials - unset PGPASSWORD - colorized_echo yellow "pg_dumpall failed, falling back to pg_dump for specific database" - local backup_user="${db_user:-${DB_USER:-postgres}}" - local backup_password="${db_password:-${DB_PASSWORD:-}}" - - if [ -z "$backup_password" ] || [ -z "$db_name" ]; then - colorized_echo red "Error: Cannot fallback - missing database name or password in SQLALCHEMY_DATABASE_URL" - error_messages+=("PostgreSQL backup failed - pg_dumpall failed and fallback credentials incomplete.") - else - colorized_echo blue "Backing up PostgreSQL database '$db_name' from container: $container_name (using app user)" - export PGPASSWORD="$backup_password" - if ! docker exec "$container_name" pg_dump -U "$backup_user" -d "$db_name" --clean --if-exists >"$temp_dir/db_backup.sql" 2>>"$log_file"; then - colorized_echo red "PostgreSQL dump failed. Check log file for details." - error_messages+=("PostgreSQL dump failed.") - else - colorized_echo green "PostgreSQL backup completed successfully" - fi - unset PGPASSWORD - fi - fi - else - # No DB_PASSWORD, use SQL URL credentials for pg_dump - local backup_user="${db_user:-${DB_USER:-postgres}}" - local backup_password="${db_password:-${DB_PASSWORD:-}}" - - if [ -z "$backup_password" ]; then - colorized_echo red "Error: Database password not found. Check DB_PASSWORD or SQLALCHEMY_DATABASE_URL in .env" - error_messages+=("PostgreSQL password not found.") - elif [ -z "$db_name" ]; then - colorized_echo red "Error: Database name not found in SQLALCHEMY_DATABASE_URL" - error_messages+=("PostgreSQL database name not found.") - else - colorized_echo blue "Backing up PostgreSQL database '$db_name' from container: $container_name (using app user)" - export PGPASSWORD="$backup_password" - if ! docker exec "$container_name" pg_dump -U "$backup_user" -d "$db_name" --clean --if-exists >"$temp_dir/db_backup.sql" 2>>"$log_file"; then - colorized_echo red "PostgreSQL dump failed. Check log file for details." - error_messages+=("PostgreSQL dump failed.") - else - colorized_echo green "PostgreSQL backup completed successfully" - fi - unset PGPASSWORD - fi - fi - fi - fi - else - # Remote database - would need postgresql-client installed - colorized_echo red "Remote PostgreSQL backup not yet supported. Please use local database or install postgresql-client." - error_messages+=("Remote PostgreSQL backup not yet supported. Please use local database or install postgresql-client.") - fi - ;; - timescaledb) - if [[ "$db_host" == "127.0.0.1" || "$db_host" == "localhost" || "$db_host" == "::1" ]]; then - if [ -z "$container_name" ]; then - colorized_echo red "Error: TimescaleDB container not found. Is the container running?" - echo "Container name detection failed. Checked for: timescaledb, postgresql" >>"$log_file" - error_messages+=("TimescaleDB container not found or not running.") - else - # Get actual container name/ID - ps -q returns container ID, which is what we need - # But first verify the container exists - local actual_container="" - if docker inspect "$container_name" >/dev/null 2>&1; then - actual_container="$container_name" - else - # Try to find container by service name using docker compose - actual_container=$($COMPOSE -f "$COMPOSE_FILE" -p "$APP_NAME" ps -q timescaledb 2>/dev/null) - if [ -z "$actual_container" ]; then - actual_container=$($COMPOSE -f "$COMPOSE_FILE" -p "$APP_NAME" ps -q postgresql 2>/dev/null) - fi - if [ -z "$actual_container" ]; then - # Try with full container name pattern - local full_container_name="${APP_NAME}-timescaledb-1" - if docker inspect "$full_container_name" >/dev/null 2>&1; then - actual_container="$full_container_name" - else - full_container_name="${APP_NAME}-postgresql-1" - if docker inspect "$full_container_name" >/dev/null 2>&1; then - actual_container="$full_container_name" - fi - fi - fi - fi - - if [ -z "$actual_container" ]; then - colorized_echo red "Error: TimescaleDB container not found. Is the container running?" - echo "Container not found. Tried: $container_name and various patterns" >>"$log_file" - error_messages+=("TimescaleDB container not found or not running.") - else - container_name="$actual_container" - # Local Docker container - # Use SQL URL credentials directly for pg_dump (more reliable than pg_dumpall) - local backup_user="${db_user:-${DB_USER:-postgres}}" - local backup_password="${db_password:-${DB_PASSWORD:-}}" - - if [ -z "$backup_password" ]; then - colorized_echo red "Error: Database password not found. Check DB_PASSWORD or SQLALCHEMY_DATABASE_URL in .env" - error_messages+=("TimescaleDB password not found.") - elif [ -z "$db_name" ]; then - colorized_echo red "Error: Database name not found in SQLALCHEMY_DATABASE_URL" - error_messages+=("TimescaleDB database name not found.") - else - colorized_echo blue "Backing up TimescaleDB database '$db_name' from container: $container_name (using user: $backup_user)" - export PGPASSWORD="$backup_password" - if ! docker exec "$container_name" pg_dump -U "$backup_user" -d "$db_name" --clean --if-exists >"$temp_dir/db_backup.sql" 2>>"$log_file"; then - colorized_echo red "TimescaleDB dump failed. Check log file for details: $log_file" - error_messages+=("TimescaleDB dump failed for database '$db_name'.") - else - colorized_echo green "TimescaleDB backup completed successfully" - fi - unset PGPASSWORD - fi - fi - fi - else - # Remote database - would need postgresql-client installed - colorized_echo red "Remote TimescaleDB backup not yet supported. Please use local database or install postgresql-client." - error_messages+=("Remote TimescaleDB backup not yet supported. Please use local database or install postgresql-client.") - fi + actual_container=$($COMPOSE -f "$COMPOSE_FILE" -p "$APP_NAME" ps -q mysql 2>/dev/null || true) + [ -z "$actual_container" ] && actual_container=$($COMPOSE -f "$COMPOSE_FILE" -p "$APP_NAME" ps -q mariadb 2>/dev/null || true) + [ -z "$actual_container" ] && [ -f "$COMPOSE_FILE" ] && actual_container="${APP_NAME}-mysql-1" ;; - sqlite) - if [ -f "$sqlite_file" ]; then - if ! cp "$sqlite_file" "$temp_dir/db_backup.sqlite" 2>>"$log_file"; then - error_messages+=("Failed to copy SQLite database.") - fi - else - error_messages+=("SQLite database file not found at $sqlite_file.") - fi + postgresql|timescaledb) + actual_container=$($COMPOSE -f "$COMPOSE_FILE" -p "$APP_NAME" ps -q postgresql 2>/dev/null || true) + [ -z "$actual_container" ] && actual_container=$($COMPOSE -f "$COMPOSE_FILE" -p "$APP_NAME" ps -q timescaledb 2>/dev/null || true) + [ -z "$actual_container" ] && [ -f "$COMPOSE_FILE" ] && actual_container="${APP_NAME}-postgresql-1" ;; esac - else - colorized_echo yellow "Warning: No database type detected. Skipping database backup." - echo "Warning: No database type detected." >>"$log_file" - echo "SQLALCHEMY_DATABASE_URL: ${SQLALCHEMY_DATABASE_URL:-not set}" >>"$log_file" - fi - - colorized_echo blue "Copying configuration files..." - if ! cp "$APP_DIR/.env" "$temp_dir/" 2>>"$log_file"; then - error_messages+=("Failed to copy .env file.") - echo "Failed to copy .env file" >>"$log_file" - fi - if ! cp "$APP_DIR/docker-compose.yml" "$temp_dir/" 2>>"$log_file"; then - error_messages+=("Failed to copy docker-compose.yml file.") - echo "Failed to copy docker-compose.yml file" >>"$log_file" fi - colorized_echo blue "Copying data directory..." - # Ensure destination directory exists and is empty (already cleaned above, but be explicit) - if [ -d "$DATA_DIR" ]; then - if ! rsync -av --exclude 'xray-core' --exclude 'mysql' "$DATA_DIR/" "$temp_dir/pasarguard_data/" >>"$log_file" 2>&1; then - error_messages+=("Failed to copy data directory.") - echo "Failed to copy data directory" >>"$log_file" - fi - else - colorized_echo yellow "Data directory $DATA_DIR does not exist. Skipping data directory backup." - echo "Data directory $DATA_DIR does not exist. Skipping." >>"$log_file" - # Create empty directory structure so tar doesn't fail - mkdir -p "$temp_dir/pasarguard_data" - fi + [ -z "$actual_container" ] && { echo ""; return 1; } + container_name="$actual_container" + docker ps --filter "id=${container_name}" --format '{{.ID}}' 2>/dev/null | grep -q . || \ + docker ps --filter "name=${container_name}" --format '{{.Names}}' 2>/dev/null | grep -q . || \ + docker ps --format '{{.Names}}' 2>/dev/null | grep -qE "^${container_name}$|/${container_name}$" || \ + docker ps --format '{{.ID}}' 2>/dev/null | grep -q "^${container_name}" || { echo ""; return 1; } + echo "$container_name" + return 0 +} - # Remove Unix socket files so zip doesn't fail with ENXIO ("No such device or address") - if [ -d "$temp_dir" ]; then - local socket_files - socket_files=$(find "$temp_dir" -type s -print 2>/dev/null || true) - if [ -n "$socket_files" ]; then - colorized_echo yellow "Removing Unix socket files before archiving (zip cannot archive sockets)." - printf "%s\n" "$socket_files" >>"$log_file" - find "$temp_dir" -type s -delete >>"$log_file" 2>&1 || true - fi - fi +verify_and_start_container() { + local container_name=$1 + local db_type=$2 + local actual_container="" - colorized_echo blue "Creating backup archive..." - # Verify temp_dir exists and has content before creating archive - if [ ! -d "$temp_dir" ] || [ -z "$(ls -A "$temp_dir" 2>/dev/null)" ]; then - error_messages+=("Temporary directory is empty or missing. Cannot create archive.") - echo "Temporary directory is empty or missing: $temp_dir" >>"$log_file" - elif ! (cd "$temp_dir" && zip -rq -s "$split_size_arg" "$backup_file" .) 2>>"$log_file"; then - error_messages+=("Failed to create backup archive.") - echo "Failed to create backup archive." >>"$log_file" + if docker inspect "$container_name" >/dev/null 2>&1; then + actual_container="$container_name" else - local backup_size=$(du -h "$backup_file" | cut -f1) - colorized_echo green "Backup archive created: $backup_file (Size: $backup_size)" - fi - - if [ -f "$backup_file" ]; then - while IFS= read -r file; do - final_backup_paths+=("$file") - done < <(find "$backup_dir" -maxdepth 1 -type f -name "backup_${timestamp}.z[0-9][0-9]" | sort) - final_backup_paths+=("$backup_file") + case $db_type in + mariadb) + actual_container=$($COMPOSE -f "$COMPOSE_FILE" -p "$APP_NAME" ps -q mariadb 2>/dev/null || true) + [ -z "$actual_container" ] && [ -f "$COMPOSE_FILE" ] && actual_container="${APP_NAME}-mariadb-1" + ;; + mysql) + actual_container=$($COMPOSE -f "$COMPOSE_FILE" -p "$APP_NAME" ps -q mysql 2>/dev/null || true) + [ -z "$actual_container" ] && actual_container=$($COMPOSE -f "$COMPOSE_FILE" -p "$APP_NAME" ps -q mariadb 2>/dev/null || true) + [ -z "$actual_container" ] && [ -f "$COMPOSE_FILE" ] && actual_container="${APP_NAME}-mysql-1" + ;; + postgresql|timescaledb) + actual_container=$($COMPOSE -f "$COMPOSE_FILE" -p "$APP_NAME" ps -q postgresql 2>/dev/null || true) + [ -z "$actual_container" ] && actual_container=$($COMPOSE -f "$COMPOSE_FILE" -p "$APP_NAME" ps -q timescaledb 2>/dev/null || true) + [ -z "$actual_container" ] && [ -f "$COMPOSE_FILE" ] && actual_container="${APP_NAME}-postgresql-1" + ;; + esac fi - # Clean up temp directory after archive is created - rm -rf "$temp_dir" + [ -z "$actual_container" ] && { echo ""; return 1; } + container_name="$actual_container" + local container_running=false + docker ps --filter "id=${container_name}" --format '{{.ID}}' 2>/dev/null | grep -q . && container_running=true || \ + docker ps --filter "name=${container_name}" --format '{{.Names}}' 2>/dev/null | grep -q . && container_running=true || \ + docker ps --format '{{.Names}}' 2>/dev/null | grep -qE "^${container_name}$|/${container_name}$" && container_running=true || \ + docker ps --format '{{.ID}}' 2>/dev/null | grep -q "^${container_name}" && container_running=true - if [ ${#error_messages[@]} -gt 0 ]; then - colorized_echo red "Backup completed with errors:" - for error in "${error_messages[@]}"; do - colorized_echo red " - $error" - done - colorized_echo yellow "Check log file: $log_file" - if [ -f "$ENV_FILE" ]; then - send_backup_error_to_telegram "${error_messages[*]}" "$log_file" - fi - return 1 + if [ "$container_running" = false ]; then + colorized_echo yellow "Database container '$container_name' is not running. Attempting to start it..." + docker start "$container_name" >/dev/null 2>&1 || \ + $COMPOSE -f "$COMPOSE_FILE" -p "$APP_NAME" start "${db_type%%|*}" 2>/dev/null || true + sleep 2 + docker ps --filter "id=${container_name}" --format '{{.ID}}' 2>/dev/null | grep -q . && container_running=true || \ + docker ps --filter "name=${container_name}" --format '{{.Names}}' 2>/dev/null | grep -q . && container_running=true fi - if [ ${#final_backup_paths[@]} -eq 0 ]; then - colorized_echo red "Backup file was not created. Check log file: $log_file" - return 1 - fi + [ "$container_running" = true ] && { echo "$container_name"; return 0; } || { echo ""; return 1; } +} + +install_pasarguard_script() { + FETCH_REPO="PasarGuard/scripts" + colorized_echo blue "Installing pasarguard script" + install_shared_libs_from_repo "$FETCH_REPO" common.sh system.sh docker.sh github.sh env.sh pasarguard-backup.sh pasarguard-restore.sh + github_install_script_from_repo "$FETCH_REPO" "pasarguard.sh" "pasarguard" + colorized_echo green "pasarguard script installed successfully" +} - if [ ${#final_backup_paths[@]} -eq 1 ]; then - colorized_echo green "Backup completed successfully: ${final_backup_paths[0]}" +is_pasarguard_installed() { + if [ -d $APP_DIR ]; then + return 0 else - colorized_echo green "Backup completed successfully in ${#final_backup_paths[@]} parts:" - for part in "${final_backup_paths[@]}"; do - colorized_echo green " - $(basename "$part")" - done - fi - if [ -f "$ENV_FILE" ]; then - send_backup_to_telegram "$backup_file" + return 1 fi } @@ -3331,7 +934,7 @@ install_pasarguard() { local database_type=$3 FILES_URL_PREFIX="https://raw.githubusercontent.com/pasarguard/panel" - COMPOSE_FILES_URL_PREFIX="https://raw.githubusercontent.com/pasarguard/scripts/main" + COMPOSE_FILES_URL_PREFIX="https://raw.githubusercontent.com/pasarguard/scripts/main/docker-compose" mkdir -p "$DATA_DIR" mkdir -p "$APP_DIR" @@ -3434,11 +1037,7 @@ install_pasarguard() { } up_pasarguard() { - $COMPOSE -f $COMPOSE_FILE -p "$APP_NAME" up -d --remove-orphans -} - -follow_pasarguard_logs() { - $COMPOSE -f $COMPOSE_FILE -p "$APP_NAME" logs -f + compose_up } status_command() { @@ -3827,93 +1426,16 @@ install_command() { follow_pasarguard_logs } -install_yq() { - if command -v yq &>/dev/null; then - colorized_echo green "yq is already installed." - return - fi - - identify_the_operating_system_and_architecture - - local base_url="https://github.com/mikefarah/yq/releases/latest/download" - local yq_binary="" - - case "$ARCH" in - '64' | 'x86_64') - yq_binary="yq_linux_amd64" - ;; - 'arm32-v7a' | 'arm32-v6' | 'arm32-v5' | 'armv7l') - yq_binary="yq_linux_arm" - ;; - 'arm64-v8a' | 'aarch64') - yq_binary="yq_linux_arm64" - ;; - '32' | 'i386' | 'i686') - yq_binary="yq_linux_386" - ;; - *) - colorized_echo red "Unsupported architecture: $ARCH" - exit 1 - ;; - esac - - local yq_url="${base_url}/${yq_binary}" - colorized_echo blue "Downloading yq from ${yq_url}..." - - if ! command -v curl &>/dev/null && ! command -v wget &>/dev/null; then - colorized_echo yellow "Neither curl nor wget is installed. Attempting to install curl." - install_package curl || { - colorized_echo red "Failed to install curl. Please install curl or wget manually." - exit 1 - } - fi - - if command -v curl &>/dev/null; then - if curl -L "$yq_url" -o /usr/local/bin/yq; then - chmod +x /usr/local/bin/yq - colorized_echo green "yq installed successfully!" - else - colorized_echo red "Failed to download yq using curl. Please check your internet connection." - exit 1 - fi - elif command -v wget &>/dev/null; then - if wget -O /usr/local/bin/yq "$yq_url"; then - chmod +x /usr/local/bin/yq - colorized_echo green "yq installed successfully!" - else - colorized_echo red "Failed to download yq using wget. Please check your internet connection." - exit 1 - fi - fi - - if ! echo "$PATH" | grep -q "/usr/local/bin"; then - export PATH="/usr/local/bin:$PATH" - fi - - hash -r - - if command -v yq &>/dev/null; then - colorized_echo green "yq is ready to use." - elif [ -x "/usr/local/bin/yq" ]; then - - colorized_echo yellow "yq is installed at /usr/local/bin/yq but not found in PATH." - colorized_echo yellow "You can add /usr/local/bin to your PATH environment variable." - else - colorized_echo red "yq installation failed. Please try again or install manually." - exit 1 - fi -} - down_pasarguard() { - $COMPOSE -f $COMPOSE_FILE -p "$APP_NAME" down + compose_down } show_pasarguard_logs() { - $COMPOSE -f $COMPOSE_FILE -p "$APP_NAME" logs + compose_logs } follow_pasarguard_logs() { - $COMPOSE -f $COMPOSE_FILE -p "$APP_NAME" logs -f + compose_logs_follow } pasarguard_cli() { @@ -4239,10 +1761,10 @@ update_command() { } update_pasarguard_script() { - FETCH_REPO="pasarguard/scripts" - SCRIPT_URL="https://github.com/$FETCH_REPO/raw/main/pasarguard.sh" + FETCH_REPO="PasarGuard/scripts" colorized_echo blue "Updating pasarguard script" - curl -sSL $SCRIPT_URL | install -m 755 /dev/stdin /usr/local/bin/pasarguard + install_shared_libs_from_repo "$FETCH_REPO" common.sh system.sh docker.sh github.sh env.sh pasarguard-backup.sh pasarguard-restore.sh + github_install_script_from_repo "$FETCH_REPO" "pasarguard.sh" "pasarguard" colorized_echo green "pasarguard script updated successfully" } @@ -4250,20 +1772,6 @@ update_pasarguard() { $COMPOSE -f $COMPOSE_FILE -p "$APP_NAME" pull } -check_editor() { - if [ -z "$EDITOR" ]; then - if command -v nano >/dev/null 2>&1; then - EDITOR="nano" - elif command -v vi >/dev/null 2>&1; then - EDITOR="vi" - else - detect_os - install_package nano - EDITOR="nano" - fi - fi -} - edit_command() { detect_os check_editor diff --git a/pg-node.sh b/pg-node.sh index f17e04a..32a0237 100755 --- a/pg-node.sh +++ b/pg-node.sh @@ -1,5 +1,28 @@ #!/usr/bin/env bash set -e + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +SHARED_LIB_DIR="${SCRIPT_DIR}/lib" +if [ ! -f "$SHARED_LIB_DIR/common.sh" ]; then + SHARED_LIB_DIR="/usr/local/lib/pasarguard-scripts/lib" +fi + +for shared_lib in common.sh system.sh docker.sh github.sh; do + if [ ! -f "$SHARED_LIB_DIR/$shared_lib" ]; then + printf 'Missing shared library: %s\n' "$SHARED_LIB_DIR/$shared_lib" >&2 + exit 1 + fi +done + +# shellcheck source=lib/common.sh +source "$SHARED_LIB_DIR/common.sh" +# shellcheck source=lib/system.sh +source "$SHARED_LIB_DIR/system.sh" +# shellcheck source=lib/docker.sh +source "$SHARED_LIB_DIR/docker.sh" +# shellcheck source=lib/github.sh +source "$SHARED_LIB_DIR/github.sh" + # Handle global options AUTO_CONFIRM=false APP_NAME="" @@ -67,44 +90,9 @@ SSL_CERT_FILE="$DATA_DIR/certs/ssl_cert.pem" SSL_KEY_FILE="$DATA_DIR/certs/ssl_key.pem" LAST_XRAY_CORES=5 FETCH_REPO="PasarGuard/scripts" -SCRIPT_URL="https://github.com/$FETCH_REPO/raw/main/pg-node.sh" NODE_SERVICE_REPO="PasarGuard/node-serviced" NODE_SERVICE_RELEASE_API="https://api.github.com/repos/${NODE_SERVICE_REPO}/releases/latest" NODE_SERVICE_BINARY_NAME="node-serviced" -colorized_echo() { - local color=$1 - local text=$2 - local style=${3:-0} # Default style is normal - case $color in - "red") - printf "\e[${style};91m${text}\e[0m\n" - ;; - "green") - printf "\e[${style};92m${text}\e[0m\n" - ;; - "yellow") - printf "\e[${style};93m${text}\e[0m\n" - ;; - "blue") - printf "\e[${style};94m${text}\e[0m\n" - ;; - "magenta") - printf "\e[${style};95m${text}\e[0m\n" - ;; - "cyan") - printf "\e[${style};96m${text}\e[0m\n" - ;; - *) - echo "${text}" - ;; - esac -} -check_running_as_root() { - if [ "$(id -u)" != "0" ]; then - colorized_echo red "This command must be run as root." - exit 1 - fi -} set_service_paths() { SERVICE_NAME="${APP_NAME}-service" SERVICE_BINARY_PATH="/usr/local/bin/${SERVICE_NAME}" @@ -184,92 +172,15 @@ configure_firewall_for_port() { local hint="If a firewall is enabled (e.g., UFW or firewalld), allow ${port}/${proto}." colorized_echo yellow "$hint" } -detect_os() { - # Detect the operating system - if [ -f /etc/lsb-release ]; then - OS=$(lsb_release -si) - elif [ -f /etc/os-release ]; then - OS=$(awk -F= '/^NAME/{print $2}' /etc/os-release | tr -d '"') - elif [ -f /etc/redhat-release ]; then - OS=$(cat /etc/redhat-release | awk '{print $1}') - elif [ -f /etc/arch-release ]; then - OS="Arch" - else - colorized_echo red "Unsupported operating system" - exit 1 - fi -} -detect_and_update_package_manager() { - colorized_echo blue "Updating package manager" - if [[ "$OS" == "Ubuntu"* ]] || [[ "$OS" == "Debian"* ]]; then - PKG_MANAGER="apt-get" - $PKG_MANAGER update -qq >/dev/null 2>&1 - elif [[ "$OS" == "CentOS"* ]] || [[ "$OS" == "AlmaLinux"* ]]; then - PKG_MANAGER="yum" - $PKG_MANAGER update -y -q >/dev/null 2>&1 - $PKG_MANAGER install -y -q epel-release >/dev/null 2>&1 - elif [[ "$OS" == "Fedora"* ]]; then - PKG_MANAGER="dnf" - $PKG_MANAGER update -q -y >/dev/null 2>&1 - elif [[ "$OS" == "Arch"* ]]; then - PKG_MANAGER="pacman" - $PKG_MANAGER -Sy --noconfirm --quiet >/dev/null 2>&1 - elif [[ "$OS" == "openSUSE"* ]]; then - PKG_MANAGER="zypper" - $PKG_MANAGER refresh --quiet >/dev/null 2>&1 - else - colorized_echo red "Unsupported operating system" - exit 1 - fi -} -detect_compose() { - # Check if docker compose command exists - if docker compose >/dev/null 2>&1; then - COMPOSE='docker compose' - elif docker-compose >/dev/null 2>&1; then - COMPOSE='docker-compose' - else - colorized_echo red "docker compose not found" - exit 1 - fi -} -install_package() { - if [ -z "$PKG_MANAGER" ]; then - detect_and_update_package_manager - fi - PACKAGE=$1 - colorized_echo blue "Installing $PACKAGE" - if [[ "$OS" == "Ubuntu"* ]] || [[ "$OS" == "Debian"* ]]; then - $PKG_MANAGER -y -qq install "$PACKAGE" >/dev/null 2>&1 - elif [[ "$OS" == "CentOS"* ]] || [[ "$OS" == "AlmaLinux"* ]]; then - $PKG_MANAGER install -y -q "$PACKAGE" >/dev/null 2>&1 - elif [[ "$OS" == "Fedora"* ]]; then - $PKG_MANAGER install -y -q "$PACKAGE" >/dev/null 2>&1 - elif [[ "$OS" == "Arch"* ]]; then - $PKG_MANAGER -S --noconfirm --quiet "$PACKAGE" >/dev/null 2>&1 - elif [[ "$OS" == "openSUSE"* ]]; then - PKG_MANAGER="zypper" - $PKG_MANAGER --quiet install -y "$PACKAGE" >/dev/null 2>&1 - else - colorized_echo red "Unsupported operating system" - exit 1 - fi -} -install_docker() { - # Install Docker and Docker Compose using the official installation script - colorized_echo blue "Installing Docker" - curl -fsSL https://get.docker.com | sh - colorized_echo green "Docker installed successfully" -} install_node_script() { colorized_echo blue "Installing node script" TARGET_PATH="/usr/local/bin/$APP_NAME" - TEMP_FILE=$(mktemp) + TEMP_FILE=$(create_temp_file "pg-node-script" ".sh") # Download script to temp file first colorized_echo cyan " Downloading script from GitHub..." - if ! curl -sSL "$SCRIPT_URL" -o "$TEMP_FILE"; then - colorized_echo red "āœ— Failed to download script from $SCRIPT_URL" + if ! github_download_file "$(github_raw_url "$FETCH_REPO" "pg-node.sh")" "$TEMP_FILE"; then + colorized_echo red "āœ— Failed to download script from $(github_raw_url "$FETCH_REPO" "pg-node.sh")" rm -f "$TEMP_FILE" exit 1 fi @@ -279,6 +190,8 @@ install_node_script() { if grep -q "^APP_NAME=" "$TEMP_FILE"; then sed -i "s|^APP_NAME=.*|APP_NAME=\"$APP_NAME\"|" "$TEMP_FILE" fi + + install_shared_libs_from_repo "$FETCH_REPO" common.sh system.sh docker.sh github.sh # Remove old file if it exists if [ -f "$TARGET_PATH" ]; then @@ -323,7 +236,7 @@ install_node_service_script() { colorized_echo red "node-serviced asset not found for platform $platform (expected $asset_name)" exit 1 fi - tmp_dir=$(mktemp -d) + tmp_dir=$(create_temp_dir "node-serviced") archive_path="${tmp_dir}/${asset_name}" colorized_echo cyan " Downloading ${asset_name}..." if ! curl -sSL "$asset_url" -o "$archive_path"; then @@ -610,7 +523,7 @@ read_and_save_file() { install_node() { local node_version=$1 FILES_URL_PREFIX="https://raw.githubusercontent.com/PasarGuard/node/main" - COMPOSE_FILES_URL_PREFIX="https://raw.githubusercontent.com/PasarGuard/scripts/main" + COMPOSE_FILES_URL_PREFIX="https://raw.githubusercontent.com/PasarGuard/scripts/main/docker-compose" colorized_echo blue "Creating directories..." colorized_echo cyan " Command: mkdir -p $DATA_DIR $DATA_DIR/certs $APP_DIR" mkdir -p "$DATA_DIR" @@ -804,20 +717,21 @@ uninstall_node_data_files() { fi } up_node() { - $COMPOSE -f $COMPOSE_FILE -p "$APP_NAME" up -d --remove-orphans + compose_up } down_node() { - $COMPOSE -f $COMPOSE_FILE -p "$APP_NAME" down + compose_down } show_node_logs() { - $COMPOSE -f $COMPOSE_FILE -p "$APP_NAME" logs + compose_logs } follow_node_logs() { - $COMPOSE -f $COMPOSE_FILE -p "$APP_NAME" logs -f + compose_logs_follow } update_node_script() { colorized_echo blue "Updating node script" - curl -sSL $SCRIPT_URL | install -m 755 /dev/stdin /usr/local/bin/$APP_NAME + install_shared_libs_from_repo "$FETCH_REPO" common.sh system.sh docker.sh github.sh + github_install_script_from_repo "$FETCH_REPO" "pg-node.sh" "$APP_NAME" colorized_echo green "node script updated successfully" } update_node() { @@ -1453,64 +1367,6 @@ update_command() { colorized_echo blue "node updated successfully" } -identify_the_operating_system_and_architecture() { - if [[ "$(uname)" == 'Linux' ]]; then - case "$(uname -m)" in - 'i386' | 'i686') - ARCH='32' - ;; - 'amd64' | 'x86_64') - ARCH='64' - ;; - 'armv5tel') - ARCH='arm32-v5' - ;; - 'armv6l') - ARCH='arm32-v6' - grep Features /proc/cpuinfo | grep -qw 'vfp' || ARCH='arm32-v5' - ;; - 'armv7' | 'armv7l') - ARCH='arm32-v7a' - grep Features /proc/cpuinfo | grep -qw 'vfp' || ARCH='arm32-v5' - ;; - 'armv8' | 'aarch64') - ARCH='arm64-v8a' - ;; - 'mips') - ARCH='mips32' - ;; - 'mipsle') - ARCH='mips32le' - ;; - 'mips64') - ARCH='mips64' - lscpu | grep -q "Little Endian" && ARCH='mips64le' - ;; - 'mips64le') - ARCH='mips64le' - ;; - 'ppc64') - ARCH='ppc64' - ;; - 'ppc64le') - ARCH='ppc64le' - ;; - 'riscv64') - ARCH='riscv64' - ;; - 's390x') - ARCH='s390x' - ;; - *) - echo "error: The architecture is not supported." - exit 1 - ;; - esac - else - echo "error: This operating system is not supported." - exit 1 - fi -} # Function to update the Xray core get_xray_core() { local requested_version="${1:-}" @@ -1650,72 +1506,6 @@ get_current_xray_core_version() { fi echo "Not installed" } -install_yq() { - if command -v yq &>/dev/null; then - colorized_echo green "yq is already installed." - return - fi - identify_the_operating_system_and_architecture - local base_url="https://github.com/mikefarah/yq/releases/latest/download" - local yq_binary="" - case "$ARCH" in - '64' | 'x86_64') - yq_binary="yq_linux_amd64" - ;; - 'arm32-v7a' | 'arm32-v6' | 'arm32-v5' | 'armv7l') - yq_binary="yq_linux_arm" - ;; - 'arm64-v8a' | 'aarch64') - yq_binary="yq_linux_arm64" - ;; - '32' | 'i386' | 'i686') - yq_binary="yq_linux_386" - ;; - *) - colorized_echo red "Unsupported architecture: $ARCH" - exit 1 - ;; - esac - local yq_url="${base_url}/${yq_binary}" - colorized_echo blue "Downloading yq from ${yq_url}..." - if ! command -v curl &>/dev/null && ! command -v wget &>/dev/null; then - colorized_echo yellow "Neither curl nor wget is installed. Attempting to install curl." - install_package curl || { - colorized_echo red "Failed to install curl. Please install curl or wget manually." - exit 1 - } - fi - if command -v curl &>/dev/null; then - if curl -L "$yq_url" -o /usr/local/bin/yq; then - chmod +x /usr/local/bin/yq - colorized_echo green "yq installed successfully!" - else - colorized_echo red "Failed to download yq using curl. Please check your internet connection." - exit 1 - fi - elif command -v wget &>/dev/null; then - if wget -O /usr/local/bin/yq "$yq_url"; then - chmod +x /usr/local/bin/yq - colorized_echo green "yq installed successfully!" - else - colorized_echo red "Failed to download yq using wget. Please check your internet connection." - exit 1 - fi - fi - if ! echo "$PATH" | grep -q "/usr/local/bin"; then - export PATH="/usr/local/bin:$PATH" - fi - hash -r - if command -v yq &>/dev/null; then - colorized_echo green "yq is ready to use." - elif [ -x "/usr/local/bin/yq" ]; then - colorized_echo yellow "yq is installed at /usr/local/bin/yq but not found in PATH." - colorized_echo yellow "You can add /usr/local/bin to your PATH environment variable." - else - colorized_echo red "yq installation failed. Please try again or install manually." - exit 1 - fi -} update_core_command() { check_running_as_root local core_version_arg="" @@ -1768,19 +1558,6 @@ update_core_command() { restart_command -n --no-restart-service colorized_echo blue "Installation of XRAY-CORE version $selected_version completed." } -check_editor() { - if [ -z "$EDITOR" ]; then - if command -v nano >/dev/null 2>&1; then - EDITOR="nano" - elif command -v vi >/dev/null 2>&1; then - EDITOR="vi" - else - detect_os - install_package nano - EDITOR="nano" - fi - fi -} edit_command() { detect_os check_editor