diff --git a/README.md b/README.md index 2460300..1c0cda2 100644 --- a/README.md +++ b/README.md @@ -233,7 +233,7 @@ PasarGuard supports 5 database engines tailored for different workloads: ## 💾 Backups & Disaster Recovery The management script backs up application settings, persistent files and -consistent database snapshots. New backups also record actual image digests, +database snapshots or dumps. New backups also record actual image digests, source database versions and a SHA256 payload inventory. - Run `pasarguard backup` for an immediate backup, or configure scheduled Telegram diff --git a/docs/backup-and-restore.fa.md b/docs/backup-and-restore.fa.md index 2631e75..a1562f4 100644 --- a/docs/backup-and-restore.fa.md +++ b/docs/backup-and-restore.fa.md @@ -67,7 +67,8 @@ sudo env APP_NAME=my-panel APP_DIR=/opt/my-panel DATA_DIR=/var/lib/my-panel \ - `backup-runtime.tsv`: زمان UTC، نوع و نسخه واقعی دیتابیس، نسخه schema در صورت دسترسی، نام/مسیرهای نصب و digest و شناسه ایمیج کانتینرهای واقعی. -- `backup-files.sha256`: checksum فایل‌ها، از جمله تنظیمات و شناسنامه. +- `backup-files.sha256`: checksum فایل‌ها، از جمله تنظیمات و شناسنامه. فایلی که نامش + backslash، CR یا LF دارد در آن ثبت نمی‌شود و هنگام بکاپ گزارش می‌شود. نسخه ایمیج از کانتینر واقعی، حتی کانتینر خاموش، ثبت می‌شود؛ صرفاً `latest` یا `lts` در Compose ملاک نیست. اگر سرویسی قابل شناسایی نباشد، `unavailable` @@ -104,11 +105,14 @@ BACKUP_PROXY_ENABLED=false آرشیوها رمز و کلید خصوصی دارند و **اسکریپت ZIP را رمزنگاری نمی‌کند**؛ دسترسی امن یا رمزنگاری نسخه خارج از سرور را فراهم کنید. checksum خرابی را تشخیص -می‌دهد و هویت ارسال‌کننده ناشناس را تأیید نمی‌کند. +می‌دهد و هویت ارسال‌کننده ناشناس را تأیید نمی‌کند. فایل‌های `.env` و Compose بلافاصله +پس از بازگردانی فقط برای مالک قابل خواندن می‌شوند. SQLite با `.backup` آنلاین snapshot می‌گیرد و `quick_check` اجرا می‌شود. PostgreSQL/TimescaleDB در صورت دسترسی، همه دیتابیس‌های کاربری و globals را -ذخیره می‌کنند؛ در fallback فقط دیتابیس تنظیم‌شده ذخیره می‌شود. زمان snapshot +ذخیره می‌کنند؛ در fallback فقط دیتابیس تنظیم‌شده ذخیره می‌شود. dump برای +MySQL/MariaDB با قفل پیش‌فرض جدول‌ها گرفته می‌شود، نه یک snapshot تراکنشی؛ پس تا +پایان dump نوشتن پنل منتظر می‌ماند. زمان snapshot دیتابیس‌های مختلف و کپی فایل‌ها یکسان نیست؛ هنگام تغییر schema/DDL بکاپ نگیرید. ## بازیابی روی نصب موجود diff --git a/docs/backup-and-restore.md b/docs/backup-and-restore.md index 54938bf..541071a 100644 --- a/docs/backup-and-restore.md +++ b/docs/backup-and-restore.md @@ -74,9 +74,9 @@ the recovery time; recovery cannot be guaranteed to take a fixed number of minut | `.env` | Application settings and secrets, including database connection fields | | `docker-compose.yml` | Source deployment configuration | | `pasarguard_data/` | Persistent application files, certificates and themes; database data directories and downloaded Xray binaries are excluded | -| SQLite snapshot or SQL dumps | Consistent database backup, not a copy of a running database data directory | +| SQLite snapshot or SQL dumps | A backup made with the database's own tools, not a copy of a running database data directory | | `backup-runtime.tsv` | Format version, UTC creation time, source engine/server version, schema revision when available, source project/paths, and actual container image digests/image IDs | -| `backup-files.sha256` | SHA256 inventory of regular payload files, including configuration and recovery metadata | +| `backup-files.sha256` | SHA256 inventory of regular payload files, including configuration and recovery metadata (a file whose name contains a backslash, CR or LF is left out and reported during the backup) | Image references come from the actual containers, including stopped containers, not from mutable `latest` or `lts` tags in the Compose template. If a service has @@ -89,7 +89,7 @@ archive. Restore backups from your own trusted storage. ZIP archives are **not encrypted by this script** and contain passwords/private keys. Store off-server copies with appropriate access control or encryption. Archives and split parts are created with private permissions; restored `.env` and Compose files are -restricted to their owner. +restricted to their owner as soon as they are restored. ## Create and keep recoverable backups @@ -106,7 +106,9 @@ the same server does not protect against complete server loss. SQLite uses the online `.backup` API and validates its snapshot with `PRAGMA quick_check`. WAL/SHM/journal files from the running database are not the restore authority. MySQL/MariaDB use the matching dump utility and verify its -completion marker. PostgreSQL/TimescaleDB attempt to dump cluster globals and +completion marker. These dumps use the utility's default table locks rather than +one transaction snapshot, so panel writes wait while the dump runs. +PostgreSQL/TimescaleDB attempt to dump cluster globals and all user databases with per-database manifests; if that is unavailable, the script falls back to the configured database. A fallback is **not** a backup of unrelated databases on the server. Each PostgreSQL dump is internally consistent; diff --git a/lib/pasarguard-backup.sh b/lib/pasarguard-backup.sh index 96eca10..e615b22 100644 --- a/lib/pasarguard-backup.sh +++ b/lib/pasarguard-backup.sh @@ -1127,8 +1127,11 @@ write_timescaledb_single_dump_version() { # containers. A tag such as latest/lts is not a reproducible recovery image. # This is data, never a shell file to source. Missing pins keep ordinary backups # usable, but fresh-server recovery must refuse to guess an image version. +# Arguments: stage, engine, database container, log, SQLite database path, and +# the database user, password and name used to read the schema revision. write_backup_runtime() { local stage="$1" engine="$2" database_container="$3" log="$4" + local sqlite_file="$5" db_user="$6" db_password="$7" db_name="$8" local runtime="$stage/backup-runtime.tsv" local server_version="unknown" dump_tool_version="unknown" revision="unknown" dump="" local services="" service="" cid="" image_id="" digest="" pins=0 @@ -1143,7 +1146,9 @@ write_backup_runtime() { dump="$stage/pg_dump/db-001.sql" fi if [ "$engine" = sqlite ]; then - server_version=$(sqlite3 --version 2>>"$log" | awk '{print $1}') || server_version="unknown" + # The panel's own SQLite library version is not visible from here; record + # the host sqlite3 that made the snapshot instead. + dump_tool_version=$(sqlite3 --version 2>>"$log" | awk '{print "sqlite3 " $1}') || dump_tool_version="unknown" # The protected snapshot is immutable. A failed metadata query against # a WAL-mode database must not create new WAL/SHM files in the archive. local snapshot_uri="$stage/$(basename "$sqlite_file")" @@ -1153,7 +1158,11 @@ write_backup_runtime() { revision=$(sqlite3 "file:${snapshot_uri}?immutable=1" 'SELECT version_num FROM alembic_version;' 2>>"$log") || revision="unknown" elif [ -n "$dump" ]; then server_version=$(sed -n -E 's/^-- (Server version[[:space:]]+|Dumped from database version )//p' "$dump" | head -n 1) - dump_tool_version=$(sed -n -E 's/^-- Dumped by pg_dump version //p; s/^-- (MySQL|MariaDB) dump .*Distrib[[:space:]]+//p' "$dump" | head -n 1) + # Headers: "-- MySQL dump 10.13 Distrib 8.0.43, for ...", MariaDB up to 11 + # the same with "Distrib", MariaDB 12 "-- MariaDB dump 10.19-12.3.2-MariaDB, for ...". + dump_tool_version=$(sed -n -E 's/^-- Dumped by pg_dump version //p + s/^-- (MySQL|MariaDB) dump .*Distrib[[:space:]]+([^,]+),.*/\2/p + s/^-- MariaDB dump [0-9.]+-([^,[:space:]]+),.*/\1/p' "$dump" | head -n 1) if [[ "$server_version" == *MariaDB* ]]; then engine="mariadb"; fi if [ -n "$database_container" ] && [ -n "$db_user" ] && [ -n "$db_name" ]; then case "$engine" in @@ -1185,8 +1194,9 @@ write_backup_runtime() { services=$($compose -f "$COMPOSE_FILE" -p "$APP_NAME" config --services 2>>"$log") || services="" while IFS= read -r service; do [ -n "$service" ] || continue - [[ "$service" =~ ^[a-zA-Z0-9][a-zA-Z0-9_.-]*$ ]] || return 1 - cid=$($compose -f "$COMPOSE_FILE" -p "$APP_NAME" ps -a -q "$service" 2>>"$log" | head -n 1) || cid="" + # Compose service names: letters, digits, ".", "_" and "-". + [[ "$service" =~ ^[a-zA-Z0-9._-]+$ ]] || return 1 + cid=$($compose -f "$COMPOSE_FILE" -p "$APP_NAME" ps -a -q -- "$service" 2>>"$log" | head -n 1) || cid="" image_id="" digest="" if [ -n "$cid" ]; then @@ -1205,13 +1215,21 @@ write_backup_runtime() { fi } -# Cover the entire archive payload, including settings and version metadata. +# Cover the archive payload, including settings and version metadata. # Checksums detect corruption; they do not authenticate an untrusted archive. +# A file whose name holds a backslash, CR or LF cannot be written as one plain +# inventory line; it is reported and left to the archive's own CRC instead of +# failing the whole backup. write_backup_checksums() ( cd "$1" || exit 1 local payload="" checksum="" while IFS= read -r -d '' payload; do - case "$payload" in *$'\n'* | *$'\r'* | *\\*) exit 1 ;; esac + case "$payload" in + *$'\n'* | *$'\r'* | *\\*) + printf 'Warning: file not in the checksum inventory (unsupported characters in its name): %q\n' "$payload" >&2 + continue + ;; + esac checksum=$(sha256sum -- "$payload") || exit 1 printf '%s\n' "$checksum" done < <(find . -type f ! -path './backup-files.sha256' -print0 | sort -z) @@ -1955,7 +1973,8 @@ backup_command() { fi if [ ${#error_messages[@]} -eq 0 ]; then - if ! write_backup_runtime "$temp_dir" "$db_type" "$container_name" "$log_file" || \ + if ! write_backup_runtime "$temp_dir" "$db_type" "$container_name" "$log_file" \ + "$sqlite_file" "$db_user" "$db_password" "$db_name" || \ ! write_backup_checksums "$temp_dir"; then error_messages+=("Failed to record recovery versions or payload checksums.") fi diff --git a/lib/pasarguard-restore.sh b/lib/pasarguard-restore.sh index 2365e32..87879f1 100644 --- a/lib/pasarguard-restore.sh +++ b/lib/pasarguard-restore.sh @@ -916,7 +916,7 @@ prepare_fresh_restore() { services=$(jq -r '.services | keys[]' "$config") || return 1 [ -n "$services" ] || return 1 while IFS= read -r service; do - [[ "$service" =~ ^[a-zA-Z0-9][a-zA-Z0-9_.-]*$ ]] || return 1 + [[ "$service" =~ ^[a-zA-Z0-9._-]+$ ]] || return 1 image=$(jq -r --arg service "$service" '.services[$service].image // empty' "$config") || return 1 if [[ ! "$image" =~ ^[a-zA-Z0-9._:/@-]+$ ]]; then colorized_echo red "Service '$service' has no usable image name in docker-compose.yml. --fresh supports image-based services only." @@ -2249,6 +2249,10 @@ restore_command() { else colorized_echo green "App directory files restored." fi + # The archived .env holds secrets: restrict it now, not only after the + # services start, so a later failure cannot leave it readable. + if [ -f "$ENV_FILE" ]; then harden_secret_file "$ENV_FILE"; fi + if [ -f "$COMPOSE_FILE" ]; then harden_secret_file "$COMPOSE_FILE"; fi fi # Keep the destination database identity. Archived credentials describe the diff --git a/tests/run_all.sh b/tests/run_all.sh index d6264a9..d0e8b35 100755 --- a/tests/run_all.sh +++ b/tests/run_all.sh @@ -16,6 +16,7 @@ SUITES=( "unit_pgnode_service.sh" "unit_restore_archive_safety.sh" "unit_restore_recovery.sh" + "unit_backup_metadata.sh" "test_script_update_safety.sh" ) diff --git a/tests/unit_backup_metadata.sh b/tests/unit_backup_metadata.sh new file mode 100644 index 0000000..377ae53 --- /dev/null +++ b/tests/unit_backup_metadata.sh @@ -0,0 +1,148 @@ +#!/usr/bin/env bash +# ============================================================================= +# unit_backup_metadata.sh - Recovery metadata written by `pasarguard backup`: +# the checksum inventory and backup-runtime.tsv, without a real Docker daemon. +# ============================================================================= +set -euo pipefail + +ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" + +WORK_DIR="$(mktemp -d)" +trap 'rm -rf "$WORK_DIR"' EXIT + +export APP_TMP_DIR="$WORK_DIR/tmp" +export APP_NAME="pasarguard-metadata-unit" +export APP_DIR="$WORK_DIR/app" +export DATA_DIR="$WORK_DIR/data" +mkdir -p "$APP_TMP_DIR" + +# A fake docker CLI: Compose lists the services in $FAKE_DOCKER_DIR/services, +# every service has container cid- running image sha256:<64 x 1>, +# whose registry digest is example/svc@sha256:<64 x 2>. +FAKE_DOCKER_DIR="$WORK_DIR/fake-docker" +export FAKE_DOCKER_DIR +mkdir -p "$WORK_DIR/bin" "$FAKE_DOCKER_DIR" +cat >"$WORK_DIR/bin/docker" <<'FAKE' +#!/usr/bin/env bash +d="${FAKE_DOCKER_DIR:?}" +printf '%s\n' "$*" >>"$d/calls.log" +case "$*" in + compose*" config --services"*) cat "$d/services" ;; + compose*" ps -a -q "*) printf 'cid-%s\n' "${@: -1}" ;; + "inspect --format {{.Image}} cid-"*) printf 'sha256:%s\n' "$(printf '1%.0s' {1..64})" ;; + "image inspect --format"*) printf 'example/%s@sha256:%s\n' "svc" "$(printf '2%.0s' {1..64})" ;; + *) exit 1 ;; +esac +FAKE +chmod 755 "$WORK_DIR/bin/docker" +export PATH="$WORK_DIR/bin:$PATH" + +# Stub network access at source time, as unit_pasarguard.sh does. +curl() { echo ""; return 0; } +export -f curl + +export PASARGUARD_SOURCE_ONLY="true" +# shellcheck source=pasarguard.sh +source "$ROOT_DIR/pasarguard.sh" +set +e +set -uo pipefail +# shellcheck disable=SC2034 # read by write_backup_runtime +COMPOSE="docker compose" + +PASS=0 +FAIL=0 +# Record and print a passed test assertion. +pass() { echo "✓ $1"; PASS=$((PASS + 1)); } +# Record and print a failed test assertion. +fail() { echo "✗ $1"; FAIL=$((FAIL + 1)); } +# Assert that the given command evaluates to true (zero exit status). +assert_true() { local l="$1"; shift; if "$@"; then pass "$l"; else fail "$l"; fi; } +# Assert that the given command evaluates to false (nonzero exit status). +assert_false() { local l="$1"; shift; if ! "$@"; then pass "$l"; else fail "$l"; fi; } +# Assert equality between actual and expected values. +assert_eq() { + local actual="$1" expected="$2" label="$3" + if [ "$actual" = "$expected" ]; then pass "$label"; else fail "$label (expected='$expected' got='$actual')"; fi +} +# Print one value from a backup-runtime.tsv. +runtime_value() { awk -F '\t' -v key="$2" '$1 == key {print $2; exit}' "$1/backup-runtime.tsv"; } + +echo "=== unit_backup_metadata.sh ===" + +# ----------------------------------------------------------------------- +# write_backup_checksums: unusual file names never fail the backup +# ----------------------------------------------------------------------- +stage="$WORK_DIR/checksums" +mkdir -p "$stage/pasarguard_data" +printf 'SQLALCHEMY_DATABASE_URL=sqlite:////var/lib/pasarguard/db.sqlite3\n' >"$stage/.env" +printf 'cert\n' >"$stage/pasarguard_data/cert.pem" +printf 'odd\n' >"$stage/pasarguard_data/back\\slash.txt" +printf 'odd\n' >"$stage/pasarguard_data/new"$'\n'"line.txt" +printf 'odd\n' >"$stage/pasarguard_data/carriage"$'\r'".txt" +write_backup_checksums "$stage" 2>"$WORK_DIR/checksums.err" +assert_eq "$?" 0 "checksums: unusual file names do not fail the backup" +assert_true "checksums: regular names are listed" grep -qF './pasarguard_data/cert.pem' "$stage/backup-files.sha256" +assert_eq "$(grep -c 'slash\|line\.txt\|carriage' "$stage/backup-files.sha256")" 0 "checksums: names the inventory cannot hold are left out" +assert_eq "$(grep -c 'not in the checksum inventory' "$WORK_DIR/checksums.err")" 3 "checksums: each left-out file is reported" +assert_true "checksums: the inventory verifies with sha256sum" bash -c "cd '$stage' && sha256sum --check --status --strict backup-files.sha256" +printf 'format\t1\n' >"$stage/backup-runtime.tsv" +write_backup_checksums "$stage" 2>/dev/null +assert_true "checksums: restore accepts the inventory" verify_backup_checksums "$stage" + +# ----------------------------------------------------------------------- +# write_backup_runtime +# ----------------------------------------------------------------------- +# Build a stage with an optional SQL dump header; $1 is the stage name. +new_stage() { + stage="$WORK_DIR/runtime-$1" + rm -rf "$stage" + mkdir -p "$stage" +} +printf 'pasarguard\nmysql\n' >"$FAKE_DOCKER_DIR/services" + +# Dump tool versions come from the dump header of each supported client. +dump_tool_case() { + local label="$1" header="$2" expected="$3" + new_stage "$label" + printf '%s\n-- Server version\t8.0.43\nCREATE TABLE t (id int);\n-- Dump completed on 2026-10-01 00:00:00\n' "$header" >"$stage/db_backup.sql" + (write_backup_runtime "$stage" mysql "" "$WORK_DIR/runtime.log" "" "" "" "") >/dev/null 2>&1 + assert_eq "$(runtime_value "$stage" dump_tool_version)" "$expected" "runtime: dump tool version from a $label header" +} +dump_tool_case "MySQL 8" '-- MySQL dump 10.13 Distrib 8.0.43, for Linux (x86_64)' "8.0.43" +dump_tool_case "MariaDB 11" '-- MariaDB dump 10.19 Distrib 10.11.6-MariaDB, for debian-linux-gnu (x86_64)' "10.11.6-MariaDB" +dump_tool_case "MariaDB 12" '-- MariaDB dump 10.19-12.3.2-MariaDB, for debian-linux-gnu (x86_64)' "12.3.2-MariaDB" + +new_stage pg +mkdir -p "$stage/pg_dump" +printf -- '-- Dumped from database version 16.14 (Debian 16.14-1.pgdg13+1)\n-- Dumped by pg_dump version 16.14 (Debian 16.14-1.pgdg13+1)\n' >"$stage/pg_dump/db-001.sql" +(write_backup_runtime "$stage" postgresql "" "$WORK_DIR/runtime.log" "" "" "" "") >/dev/null 2>&1 +assert_eq "$(runtime_value "$stage" server_version)" "16.14 (Debian 16.14-1.pgdg13+1)" "runtime: PostgreSQL server version from the dump" +assert_eq "$(runtime_value "$stage" dump_tool_version)" "16.14 (Debian 16.14-1.pgdg13+1)" "runtime: pg_dump version from the dump" + +# SQLite: the panel's SQLite library version is unknown here; the snapshot tool is recorded. +if command -v sqlite3 >/dev/null 2>&1; then + new_stage sqlite + sqlite3 "$stage/db.sqlite3" "CREATE TABLE alembic_version (version_num varchar(32)); INSERT INTO alembic_version VALUES ('rev1');" + (write_backup_runtime "$stage" sqlite "" "$WORK_DIR/runtime.log" "$DATA_DIR/db.sqlite3" "" "" "") >/dev/null 2>&1 + assert_eq "$(runtime_value "$stage" server_version)" "unknown" "runtime: SQLite server version is not guessed from the host CLI" + assert_eq "$(runtime_value "$stage" dump_tool_version)" "sqlite3 $(sqlite3 --version | awk '{print $1}')" "runtime: SQLite snapshot tool recorded" + assert_eq "$(runtime_value "$stage" schema_revision)" "rev1" "runtime: SQLite schema revision read from the snapshot" +else + echo "(skipped SQLite runtime cases: sqlite3 unavailable)" +fi + +# Compose allows service names that start with "_", "." or "-"; they must not fail the backup. +new_stage names +printf 'pasarguard\n_worker\n.hidden\n-dash\n' >"$FAKE_DOCKER_DIR/services" +: >"$FAKE_DOCKER_DIR/calls.log" +(write_backup_runtime "$stage" sqlite "" "$WORK_DIR/runtime.log" "" "" "" "") >/dev/null 2>&1 +assert_eq "$?" 0 "runtime: Compose service names with a leading _, . or - accepted" +assert_eq "$(awk -F '\t' '$1 == "image" {print $2}' "$stage/backup-runtime.tsv" | paste -sd ' ')" "pasarguard _worker .hidden -dash" "runtime: every service recorded" +assert_true "runtime: a service name starting with - is not read as an option" grep -q 'ps -a -q -- -dash' "$FAKE_DOCKER_DIR/calls.log" +printf 'pasarguard\nbad\tname\n' >"$FAKE_DOCKER_DIR/services" +(write_backup_runtime "$stage" sqlite "" "$WORK_DIR/runtime.log" "" "" "" "") >/dev/null 2>&1 +assert_eq "$?" 1 "runtime: a service name with a tab is still refused" + +echo "" +echo "Results: $PASS passed, $FAIL failed" +[ "$FAIL" -eq 0 ] || exit 1