From a7417383672da5031d6c352c57d03bfd48c858f7 Mon Sep 17 00:00:00 2001 From: T3ST3ST3R0N Date: Fri, 9 Oct 2026 19:50:20 +0330 Subject: [PATCH 1/2] fix(restore): refuse cross-engine restores and keep data safety fail-closed Follow-ups from the review of #34: - Refuse an ordinary restore whose backup comes from another database engine family than the destination (for example a SQLite backup onto a PostgreSQL installation), before any service or file changes. - Probe the destination MySQL/MariaDB version with the same credential order the import uses, and tell a refused restore (incompatible version) apart from an unreadable destination version, so the credentials hint only follows the latter. - Save the current data directory with rsync before replacing it and stop if that copy fails, like the application directory. - Share one anchored DATA_DIR exclude list between backup and restore. Restore no longer deletes a destination's xray-core directory that backups never contain, and nested directories named like a database are no longer skipped. - Reject symbolic and hard link members from the archive listing before extracting. --- docs/backup-and-restore.fa.md | 8 ++- docs/backup-and-restore.md | 13 ++-- lib/common.sh | 14 ++++ lib/pasarguard-backup.sh | 2 +- lib/pasarguard-restore.sh | 102 +++++++++++++++++++++++---- tests/unit_restore_archive_safety.sh | 18 +++++ tests/unit_restore_recovery.sh | 79 ++++++++++++++++++++- 7 files changed, 214 insertions(+), 22 deletions(-) diff --git a/docs/backup-and-restore.fa.md b/docs/backup-and-restore.fa.md index 2631e75..28a03dc 100644 --- a/docs/backup-and-restore.fa.md +++ b/docs/backup-and-restore.fa.md @@ -123,13 +123,17 @@ sudo pasarguard restore `--file /path/to/backup.zip` معادل آن است. `--yes` تأیید تعاملی را حذف می‌کند، اما بررسی‌های سلامت همچنان انجام می‌شوند. -برای دیتابیس سروری، Compose و مشخصات اتصال مقصد حفظ می‌شوند. وقتی نسخه مبدا +برای دیتابیس سروری، Compose و مشخصات اتصال مقصد حفظ می‌شوند. بکاپ نوع دیگری از +دیتابیس (مثلاً بکاپ SQLite روی نصب PostgreSQL) پیش از هر تغییری رد می‌شود. وقتی نسخه مبدا مشخص باشد، ورود MySQL به MariaDB یا برعکس و بازیابی روی نسخه قدیمی‌تر، قبل از SQL متوقف می‌شود. مقایسه نسخه، سازگاری همه SQLهای اختصاصی را تضمین نمی‌کند. قبل از بازنویسی نصب موجود، بکاپ مستقل بگیرید. import SQL و بازیابی چند دیتابیس rollback یکپارچه ندارند؛ ممکن است یک دیتابیس موفق و بعدی ناموفق باشد. برای -SQLite، snapshot ایمنی و برای فایل‌های برنامه، کپی پیش از جایگزینی تهیه می‌شود. +SQLite، snapshot ایمنی و برای فایل‌های برنامه و داده، کپی پیش از جایگزینی تهیه +می‌شود؛ اگر این کپی شکست بخورد، بازیابی متوقف می‌شود. فضای دیتابیس و فایل‌های +Xray داخل پوشه داده دست‌نخورده می‌مانند و کپی نمی‌شوند. آرشیو دارای symbolic link +یا hard link پیش از استخراج رد می‌شود. اگر `--fresh` پس از آماده‌سازی نصب شکست خورد، پنل اجرا نمی‌شود و داده‌ها برای بررسی باقی می‌مانند. پس از اصلاح علت در لاگ، اگر Compose و تنظیمات مقصد ساخته diff --git a/docs/backup-and-restore.md b/docs/backup-and-restore.md index 54938bf..1d9d1eb 100644 --- a/docs/backup-and-restore.md +++ b/docs/backup-and-restore.md @@ -145,14 +145,19 @@ still applies. Use `pasarguard restore --help` for the options. Ordinary restore preserves the destination Compose file for server databases and the provisioned destination database credentials/connection URL. It validates -payloads before stopping application writers. When source-version information is -available, it refuses MySQL-to-MariaDB/MariaDB-to-MySQL imports and database +payloads before stopping application writers. It refuses a backup from another +database engine family (for example a SQLite backup onto a PostgreSQL +installation) before changing anything. When source-version information is +available, it also refuses MySQL-to-MariaDB/MariaDB-to-MySQL imports and database version downgrades before executing the import. Restore to the original engine and version when diagnosing an old backup. An allowed version comparison is not a guarantee that every vendor-specific SQL statement is compatible. -Application/data files are saved before replacement, and SQLite receives a -pre-restore safety snapshot. Server SQL imports are **not transactional recovery +Application/data files are saved before replacement (the restore stops if that +copy fails; database storage and Xray binaries inside the data directory are +left in place rather than copied), and SQLite receives a pre-restore safety +snapshot. Archives containing symbolic or hard links are refused before +extraction. Server SQL imports are **not transactional recovery of the whole host**: an import can fail after changing data, and multi-database restores can finish earlier databases before a later one fails. Take a separate current backup before replacing an existing installation. Fresh recovery leaves diff --git a/lib/common.sh b/lib/common.sh index 16b0464..8536fb0 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -93,6 +93,20 @@ sqlite_absolute_database_url() { printf '%s:////%s\n' "$driver" "${path#/}" } +# rsync excludes for DATA_DIR, shared by backup and restore so they cannot drift +# apart: database server storage (backed up as SQL dumps instead) and downloaded +# Xray binaries. Backup skips them and restore's `rsync --delete` leaves them in +# place. The leading slash anchors each pattern to the top of DATA_DIR, so a +# nested directory with the same name is still part of the data. +# shellcheck disable=SC2034 # used by pasarguard-backup.sh and pasarguard-restore.sh +PASARGUARD_DATA_DIR_EXCLUDES=( + --exclude=/xray-core + --exclude=/mysql + --exclude=/mariadb + --exclude=/postgresql + --exclude=/timescaledb +) + # Ensure a secret-bearing file (e.g. .env, TLS private key) is only readable by # its owner. Creates the file with 0600 if it is missing so callers can harden # it *before* writing secrets; tightens it to 0600 if it already exists. A diff --git a/lib/pasarguard-backup.sh b/lib/pasarguard-backup.sh index 96eca10..4dd71fb 100644 --- a/lib/pasarguard-backup.sh +++ b/lib/pasarguard-backup.sh @@ -1889,7 +1889,7 @@ backup_command() { colorized_echo blue "Copying data directory..." # Ensure destination directory exists and is empty (already cleaned above, but be explicit) if [ -d "$DATA_DIR" ]; then - local rsync_args=(-av --exclude 'xray-core' --exclude 'mysql' --exclude 'mariadb' --exclude 'postgresql' --exclude 'timescaledb') + local rsync_args=(-av "${PASARGUARD_DATA_DIR_EXCLUDES[@]}") local normalized_data_dir="" normalized_data_dir=$(normalize_posix_path "$DATA_DIR") diff --git a/lib/pasarguard-restore.sh b/lib/pasarguard-restore.sh index 2365e32..82bbf6b 100644 --- a/lib/pasarguard-restore.sh +++ b/lib/pasarguard-restore.sh @@ -1,5 +1,17 @@ #!/usr/bin/env bash +# True when the archive lists a symbolic or hard link member (or cannot be +# listed). Checked before extraction; backups never contain links. +archive_has_links() { + local archive="$1" kind="$2" listing="" + case "$kind" in + zip) listing=$(unzip -Z "$archive" 2>/dev/null) || return 0 ;; + tar) listing=$(tar -tvzf "$archive" 2>/dev/null) || return 0 ;; + *) return 0 ;; + esac + grep -q '^[lh]' <<<"$listing" +} + # Reject archives whose members would escape the extraction directory — an # absolute path or a '..' component (zip-slip / tar path traversal). Backups # are later rsynced into $DATA_DIR/$APP_DIR as root, so a tampered archive must @@ -701,6 +713,24 @@ pg_restore_all_user_databases() { [ "$total" -gt 0 ] && [ "$ok" -eq "$total" ] } +# Copy DATA_DIR aside before a restore replaces it. Database storage and Xray +# binaries are skipped: the restore leaves them in place. +save_data_dir_safety_copy() { + local destination="$1" log="$2" + rsync -a "${PASARGUARD_DATA_DIR_EXCLUDES[@]}" "$DATA_DIR/" "$destination/" 2>>"$log" +} + +# Map an engine name or a SQLAlchemy URL to its engine family: sqlite, mysql +# (MySQL and MariaDB) or postgresql (PostgreSQL and TimescaleDB). Prints nothing +# for anything else. +database_engine_family() { + case "$1" in + sqlite*) echo sqlite ;; + mysql* | mariadb*) echo mysql ;; + postgresql* | timescaledb*) echo postgresql ;; + esac +} + # Read recovery metadata as data, without evaluating archived shell input. backup_runtime_value() { local stage="$1" key="$2" @@ -764,6 +794,8 @@ print_backup_runtime() { # Reject cross-engine imports and version downgrades before the first SQL # statement. Upgrades within one engine still use its normal logical restore # path (and the existing TimescaleDB compatibility conversion). +# Returns 0 when compatible, 1 when refused (with the reason printed) and 2 +# when the destination version could not be read. check_restore_database_version() { local stage="$1" engine="$2" container="$3" log="$4" local source_version="" target_version="" client="mysql" user="" password="" @@ -777,13 +809,22 @@ check_restore_database_version() { case "$engine" in mysql|mariadb) if docker exec "$container" mariadb --version >/dev/null 2>&1; then client=mariadb; fi - if [ -n "$current_mysql_root_password" ]; then - user=root; password="$current_mysql_root_password" - else - user="${current_db_user:-$db_user}"; password="${current_db_password:-$db_password}" - fi - target_version=$(docker exec -e MYSQL_PWD="$password" "$container" "$client" \ - -u "$user" -N -s -e 'SELECT VERSION();' 2>>"$log") || return 1 + # Same credential order as the import: current root, backup root, + # backup app user, current app user. + local credentials=() i=0 + [ -z "${current_mysql_root_password:-}" ] || credentials+=(root "$current_mysql_root_password") + [ -z "${MYSQL_ROOT_PASSWORD:-}" ] || credentials+=(root "$MYSQL_ROOT_PASSWORD") + [ -z "${db_user:-}" ] || credentials+=("$db_user" "${db_password:-}") + [ -z "${current_db_user:-}" ] || credentials+=("$current_db_user" "${current_db_password:-}") + for ((i = 0; i < ${#credentials[@]}; i += 2)); do + user="${credentials[i]}"; password="${credentials[i + 1]}" + if target_version=$(docker exec -e MYSQL_PWD="$password" "$container" "$client" \ + -u "$user" -N -s -e 'SELECT VERSION();' 2>>"$log"); then + break + fi + target_version="" + done + [ -n "$target_version" ] || return 2 if { [[ "$source_version" == *MariaDB* ]] && [[ "$target_version" != *MariaDB* ]]; } || \ { [[ "$source_version" != *MariaDB* ]] && [[ "$target_version" == *MariaDB* ]]; }; then colorized_echo red "Backup engine ($source_version) differs from destination ($target_version). Restore with the original engine, or use --fresh on an empty server." @@ -793,11 +834,11 @@ check_restore_database_version() { postgresql|timescaledb) user="${current_db_user:-${db_user:-postgres}}"; password="${current_db_password:-$db_password}" target_version=$(docker exec -e PGPASSWORD="$password" "$container" psql -X -U "$user" -d postgres -At \ - -c 'SHOW server_version;' 2>>"$log") || return 1 + -c 'SHOW server_version;' 2>>"$log") || return 2 ;; *) return 0 ;; esac - [[ "$target_version" =~ ^([0-9]+)\.([0-9]+) ]] || return 1 + [[ "$target_version" =~ ^([0-9]+)\.([0-9]+) ]] || return 2 local target_major="${BASH_REMATCH[1]}" target_minor="${BASH_REMATCH[2]}" if [ "$source_major" -gt "$target_major" ] || \ { [[ "$engine" =~ ^(mysql|mariadb)$ ]] && [ "$source_major" -eq "$target_major" ] && [ "$source_minor" -gt "$target_minor" ]; }; then @@ -1457,6 +1498,12 @@ restore_command() { rm -rf "$temp_restore_dir" exit 1 fi + if archive_has_links "$archive_to_extract" zip; then + colorized_echo red "ERROR: The backup archive contains symbolic or hard links. Repackage it with regular files before restoring." + echo "Link members detected in $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" @@ -1476,6 +1523,12 @@ restore_command() { rm -rf "$temp_restore_dir" exit 1 fi + if archive_has_links "$archive_to_extract" tar; then + colorized_echo red "ERROR: The backup archive contains symbolic or hard links. Repackage it with regular files before restoring." + echo "Link members detected in $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" @@ -1719,6 +1772,17 @@ restore_command() { colorized_echo green "Backup validation passed. No services or destination data were changed. This check does not perform a database import." return 0 fi + # Ordinary restore keeps the destination's database server. A backup from + # another engine family would replace its configuration instead (for SQLite, + # the restored .env and Compose file drop the database service). + if [ "$fresh_restore" = false ] && [ -n "$current_sqlalchemy_url" ]; then + local destination_engine="" + destination_engine=$(database_engine_family "$current_sqlalchemy_url") + if [ -n "$destination_engine" ] && [ "$(database_engine_family "$db_type")" != "$destination_engine" ]; then + colorized_echo red "Backup engine ($db_type) differs from destination ($destination_engine). Restore with the original engine, or use --fresh on an empty server." + cleanup_and_exit_restore_error 1 + fi + fi if [ "$fresh_restore" = true ]; then if [ "$db_type" != sqlite ] && ! is_local_db_host "$db_host"; then colorized_echo red "--fresh requires a local database from the official Compose templates." @@ -1785,10 +1849,12 @@ restore_command() { colorized_echo red "Destination database could not be started for compatibility checks." cleanup_and_exit_restore_error 1 fi - if ! check_restore_database_version "$temp_restore_dir" "$db_type" "$container_name" "$log_file"; then - colorized_echo red "Could not validate database version compatibility. Check destination credentials and the restore log." - cleanup_and_exit_restore_error 1 + local version_check=0 + check_restore_database_version "$temp_restore_dir" "$db_type" "$container_name" "$log_file" || version_check=$? + if [ "$version_check" -eq 2 ]; then + colorized_echo red "Could not read the destination database version. Check destination credentials and the restore log." fi + [ "$version_check" -eq 0 ] || cleanup_and_exit_restore_error 1 fi # Stop pasarguard services before restore for clean state @@ -2178,11 +2244,19 @@ restore_command() { install_package rsync fi mkdir -p "$DATA_DIR" + # rsync --delete below relies on these excludes to keep database storage. + if [ "${#PASARGUARD_DATA_DIR_EXCLUDES[@]}" -eq 0 ]; then + colorized_echo red "The data directory exclude list is missing (mismatched script libraries). Refusing to sync the data directory." + cleanup_and_exit_restore_error 1 + fi if [ "$fresh_restore" = false ] && [ "$(ls -A "$DATA_DIR" 2>/dev/null)" ]; then colorized_echo blue "Backing up current data directory before restore..." - cp -r "$DATA_DIR" "$DATA_DIR.backup.$(date +%Y%m%d%H%M%S)" 2>>"$log_file" || true + if ! save_data_dir_safety_copy "$DATA_DIR.backup.$(date +%Y%m%d%H%M%S)" "$log_file"; then + colorized_echo red "Failed to save the current data directory before replacement." + cleanup_and_exit_restore_error 1 + fi fi - if ! rsync -a --delete --exclude mysql --exclude mariadb --exclude postgresql --exclude timescaledb "$extracted_data_dir/" "$DATA_DIR/" 2>>"$log_file"; then + if ! rsync -a --delete "${PASARGUARD_DATA_DIR_EXCLUDES[@]}" "$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" cleanup_and_exit_restore_error 1 diff --git a/tests/unit_restore_archive_safety.sh b/tests/unit_restore_archive_safety.sh index cbc8871..0d6c2c2 100644 --- a/tests/unit_restore_archive_safety.sh +++ b/tests/unit_restore_archive_safety.sh @@ -56,6 +56,24 @@ else echo "(skipped zip cases: zip/unzip unavailable)" fi +# Symbolic and hard links are rejected from the listing, before extraction. +mkdir -p links +echo target > links/target.txt +ln -s target.txt links/symlink +tar -czf symlink.tgz -C links . +rm links/symlink && ln links/target.txt links/hardlink +tar -czf hardlink.tgz -C links . +assert_true "archive_has_links: tar symlink found" archive_has_links symlink.tgz tar +assert_true "archive_has_links: tar hard link found" archive_has_links hardlink.tgz tar +assert_false "archive_has_links: clean tar passes" archive_has_links safe.tgz tar +if command -v zip >/dev/null 2>&1 && command -v unzip >/dev/null 2>&1; then + rm links/hardlink && ln -s target.txt links/symlink + (cd links && zip -qry "$WORK_DIR/symlink.zip" .) + assert_true "archive_has_links: zip symlink found" archive_has_links symlink.zip zip + assert_false "archive_has_links: clean zip passes" archive_has_links safe.zip zip +fi +assert_true "archive_has_links: unreadable archive treated as unsafe" archive_has_links /no/such.tgz tar + # Unknown kind and unreadable archive are treated as unsafe. assert_false "archive_entries_are_safe: unknown kind rejected" archive_entries_are_safe safe.tgz bogus assert_false "archive_entries_are_safe: missing archive rejected" archive_entries_are_safe /no/such.tgz tar diff --git a/tests/unit_restore_recovery.sh b/tests/unit_restore_recovery.sh index 696af0e..c4494f8 100644 --- a/tests/unit_restore_recovery.sh +++ b/tests/unit_restore_recovery.sh @@ -54,7 +54,13 @@ case "$1" in exit "$code" ;; *" mariadb --version"*) exit "$(cat "$d/has_mariadb")" ;; - *) cat "$d/target_version" ;; + *) + # With accept_pwd set, only a client call carrying that password works. + if [ -s "$d/accept_pwd" ]; then + case " $* " in *" MYSQL_PWD=$(cat "$d/accept_pwd") "* | *" PGPASSWORD=$(cat "$d/accept_pwd") "*) ;; *) exit 1 ;; esac + fi + cat "$d/target_version" + ;; esac ;; *) exit 1 ;; @@ -252,6 +258,23 @@ if command -v sqlite3 >/dev/null 2>&1 && command -v zip >/dev/null 2>&1 && comma out=$(run_restore "$WORK_DIR/archives/edited.zip" --check 2>&1) assert_eq "$?" 1 "restore --check: edited payload rejected" + # An ordinary restore never switches the destination's database engine. + make_sqlite_archive cross keep + mkdir -p "$APP_DIR" "$DATA_DIR" + printf 'DB_USER=pg\nDB_PASSWORD=pgpass\nDB_NAME=panel\nSQLALCHEMY_DATABASE_URL="postgresql+asyncpg://pg:pgpass@127.0.0.1:5432/panel"\n' >"$ENV_FILE" + printf 'services:\n pasarguard:\n image: pasarguard/panel:latest\n postgresql:\n image: postgres:16\n' >"$COMPOSE_FILE" + cp "$ENV_FILE" "$WORK_DIR/env.before" + cp "$COMPOSE_FILE" "$WORK_DIR/compose.before" + : >"$FAKE_DOCKER_DIR/calls.log" + out=$(run_restore "$WORK_DIR/archives/cross.zip" --yes 2>&1) + rc=$? + assert_eq "$rc" 1 "restore: SQLite backup onto a PostgreSQL installation refused" + case "$out" in *"Backup engine (sqlite) differs from destination (postgresql)"*) pass "restore: engine mismatch explained" ;; *) fail "restore: engine mismatch explained" ;; esac + assert_true "restore: engine mismatch leaves .env unchanged" cmp -s "$ENV_FILE" "$WORK_DIR/env.before" + assert_true "restore: engine mismatch leaves docker-compose.yml unchanged" cmp -s "$COMPOSE_FILE" "$WORK_DIR/compose.before" + assert_false "restore: engine mismatch stops no services" grep -qE ' (stop|down)( |$)' "$FAKE_DOCKER_DIR/calls.log" + rm -rf "$APP_DIR" "$DATA_DIR" + make_sqlite_archive noinventory drop out=$(run_restore "$WORK_DIR/archives/noinventory.zip" --check 2>&1) assert_eq "$?" 1 "restore --check: recovery metadata without inventory rejected" @@ -292,6 +315,34 @@ assert_false "version guard: MariaDB backup into MySQL rejected" version_guard " assert_true "version guard: MariaDB 11.4 backup into 11.8 allowed" version_guard "11.4.2-MariaDB" "11.8.1-MariaDB" mariadb printf 'format\t1\nserver_version\tunknown\n' >"$stage/backup-runtime.tsv" : >"$FAKE_DOCKER_DIR/calls.log" +# The probe tries the credentials the import tries: current root, backup root, +# backup app user, current app user. +echo 1 >"$FAKE_DOCKER_DIR/has_mariadb" +# shellcheck disable=SC2034 # read by check_restore_database_version +MYSQL_ROOT_PASSWORD="backup-root-pass" +for accepted in root-pass backup-root-pass app-pass; do + printf '%s' "$accepted" >"$FAKE_DOCKER_DIR/accept_pwd" + assert_true "version guard: MySQL probe works with credential '$accepted'" version_guard "8.0.39" "8.4.3" mysql +done +printf 'app-pass' >"$FAKE_DOCKER_DIR/accept_pwd" +: >"$FAKE_DOCKER_DIR/calls.log" +version_guard "8.0.39" "8.4.3" mysql +tried=$(grep -o 'MYSQL_PWD=[^ ]*' "$FAKE_DOCKER_DIR/calls.log" | paste -sd ' ') +assert_eq "$tried" "MYSQL_PWD=root-pass MYSQL_PWD=backup-root-pass MYSQL_PWD=app-pass" "version guard: credentials tried in the import's order" +printf 'nothing-matches' >"$FAKE_DOCKER_DIR/accept_pwd" +printf 'format\t1\nserver_version\t8.0.39\n' >"$stage/backup-runtime.tsv" +check_restore_database_version "$stage" mysql cid-db "$WORK_DIR/version.log" >/dev/null 2>&1 +assert_eq "$?" 2 "version guard: unreadable destination version returns 2" +: >"$FAKE_DOCKER_DIR/accept_pwd" +check_restore_database_version "$stage" mysql cid-db "$WORK_DIR/version.log" >/dev/null 2>&1 +assert_eq "$?" 0 "version guard: compatible destination returns 0" +printf '8.0.1\n' >"$FAKE_DOCKER_DIR/target_version" +printf 'format\t1\nserver_version\t8.4.3\n' >"$stage/backup-runtime.tsv" +check_restore_database_version "$stage" mysql cid-db "$WORK_DIR/version.log" >/dev/null 2>&1 +assert_eq "$?" 1 "version guard: refused downgrade returns 1" +unset MYSQL_ROOT_PASSWORD +printf 'format\t1\nserver_version\tunknown\n' >"$stage/backup-runtime.tsv" +: >"$FAKE_DOCKER_DIR/calls.log" assert_true "version guard: unknown source version is not guessed" check_restore_database_version "$stage" postgresql cid-db "$WORK_DIR/version.log" assert_eq "$(wc -l <"$FAKE_DOCKER_DIR/calls.log")" 0 "version guard: unknown source version makes no docker calls" printf 'format\t1\nserver_version\tunknown\n' >"$stage/backup-runtime.tsv" @@ -299,6 +350,32 @@ printf -- '-- Dumped from database version 17.2\n' >"$stage/db_backup.sql" assert_false "version guard: dump header version used when metadata is unknown" version_guard unknown "16.4" postgresql rm -f "$stage/db_backup.sql" +# ----------------------------------------------------------------------- +# Data directory: shared excludes and the pre-restore safety copy +# ----------------------------------------------------------------------- +rm -rf "$DATA_DIR" "$WORK_DIR/extracted" +mkdir -p "$DATA_DIR/xray-core" "$DATA_DIR/mysql" "$DATA_DIR/certs/mysql" "$WORK_DIR/extracted/certs" +printf 'binary\n' >"$DATA_DIR/xray-core/xray" +printf 'ibdata\n' >"$DATA_DIR/mysql/ibdata1" +printf 'old\n' >"$DATA_DIR/certs/mysql/ca.pem" +printf 'stale\n' >"$DATA_DIR/stale.txt" +printf 'restored\n' >"$WORK_DIR/extracted/certs/cert.pem" +rsync -a --delete "${PASARGUARD_DATA_DIR_EXCLUDES[@]}" "$WORK_DIR/extracted/" "$DATA_DIR/" +assert_true "data sync: Xray binaries kept" test -f "$DATA_DIR/xray-core/xray" +assert_true "data sync: top-level database storage kept" test -f "$DATA_DIR/mysql/ibdata1" +assert_false "data sync: nested directory named mysql is part of the data" test -e "$DATA_DIR/certs/mysql" +assert_false "data sync: files missing from the backup are removed" test -e "$DATA_DIR/stale.txt" +assert_true "data sync: backed-up files restored" test -f "$DATA_DIR/certs/cert.pem" + +printf 'old\n' >"$DATA_DIR/certs/mysql-ca.pem" +assert_true "data safety copy: succeeds" save_data_dir_safety_copy "$WORK_DIR/data.copy" "$WORK_DIR/copy.log" +assert_true "data safety copy: data files copied" test -f "$WORK_DIR/data.copy/certs/mysql-ca.pem" +assert_false "data safety copy: database storage not copied" test -e "$WORK_DIR/data.copy/mysql" +assert_false "data safety copy: Xray binaries not copied" test -e "$WORK_DIR/data.copy/xray-core" +printf 'not a directory\n' >"$WORK_DIR/blocker" +assert_false "data safety copy: failure is reported" save_data_dir_safety_copy "$WORK_DIR/blocker/copy" "$WORK_DIR/copy.log" +rm -rf "$DATA_DIR" "$WORK_DIR/extracted" "$WORK_DIR/data.copy" "$WORK_DIR/blocker" + # ----------------------------------------------------------------------- # wait_for_recovery_database (docker and sleep mocked) # ----------------------------------------------------------------------- From 0cb8eff84a3b6cbd7f4217147e17eb75a4f9432c Mon Sep 17 00:00:00 2001 From: T3ST3ST3R0N Date: Sat, 10 Oct 2026 18:03:27 +0330 Subject: [PATCH 2/2] docs(restore): name the engine family in the Farsi cross-engine refusal The restore compares engine families, so a MySQL backup on a MariaDB install is only refused when the source version is known. The Farsi guide said any other database type is refused; it now matches the English guide. --- docs/backup-and-restore.fa.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/backup-and-restore.fa.md b/docs/backup-and-restore.fa.md index 28a03dc..a45202b 100644 --- a/docs/backup-and-restore.fa.md +++ b/docs/backup-and-restore.fa.md @@ -123,8 +123,9 @@ sudo pasarguard restore `--file /path/to/backup.zip` معادل آن است. `--yes` تأیید تعاملی را حذف می‌کند، اما بررسی‌های سلامت همچنان انجام می‌شوند. -برای دیتابیس سروری، Compose و مشخصات اتصال مقصد حفظ می‌شوند. بکاپ نوع دیگری از -دیتابیس (مثلاً بکاپ SQLite روی نصب PostgreSQL) پیش از هر تغییری رد می‌شود. وقتی نسخه مبدا +برای دیتابیس سروری، Compose و مشخصات اتصال مقصد حفظ می‌شوند. بکاپ از خانواده دیگری از +موتورهای دیتابیس (مثلاً بکاپ SQLite روی نصب PostgreSQL) پیش از هر تغییری رد می‌شود؛ +MySQL و MariaDB یک خانواده‌اند. وقتی نسخه مبدا مشخص باشد، ورود MySQL به MariaDB یا برعکس و بازیابی روی نسخه قدیمی‌تر، قبل از SQL متوقف می‌شود. مقایسه نسخه، سازگاری همه SQLهای اختصاصی را تضمین نمی‌کند.