不使用 Databasus 从备份恢复 PostgreSQL

备份不只是保护数据,还包括把数据恢复出来。Databasus 特别注意让你的备份始终可恢复:即使装有 Databasus 的 VPS 被删除、你丢失了访问权限或因故打不开界面。备份以标准格式 存储,没有供应商锁定,所以恢复备份并不需要 Databasus。

你需要什么

手动恢复备份需要:

  • 备份文件,来自你的存储(本地存储、S3、 Google Drive 等)
  • 元数据文件,来自同一存储。它与备份文件 同名,只是多了 .metadata 扩展名。
  • 密钥文件,位于 ./databasus-data/secret.key(与备份文件在 同一目录,通常是 /opt/databasus/

文件结构

每个备份由两个文件组成,存放在你的存储中(本地或云端):

  • {database-name}-{timestamp}-{backup-id}:加密并压缩后的备份数据
  • {database-name}-{timestamp}-{backup-id}.metadata:包含加密信息的 JSON 文件

元数据文件包含 Base64 格式的加密盐和 IV(nonce):

{
  "backupId": "550e8400-e29b-41d4-a716-446655440000",
  "encryptionSalt": "base64-encoded-salt",
  "encryptionIV": "base64-encoded-nonce",
  "encryption": "encrypted"
}

解密

Databasus 使用 AES-256-GCM 加密,并通过 PBKDF2 派生密钥。每个备份都有独立的加密 密钥,由以下部分派生:

  • 主密钥(来自 secret.key 文件)
  • 备份 ID
  • 随机盐(保存在元数据中)

使用这个 Python 脚本解密备份:

import json
import base64
import struct
import os
from Crypto.Cipher import AES
from Crypto.Protocol.KDF import PBKDF2
from Crypto.Hash import SHA256

# Constants from Databasus encryption
MAGIC_BYTES = b"PGRSUS01"
HEADER_LENGTH = 64
CHUNK_SIZE = 1024 * 1024
PBKDF2_ITERATIONS = 100000


def decrypt_backup(backup_file, metadata_file, master_key):
    """
    Decrypt a Databasus backup file using metadata and master key.

    Args:
        backup_file: Path to encrypted backup file
        metadata_file: Path to metadata JSON file
        master_key: Master key from ./databasus-data/secret.key
    """
    # Validate files exist
    if not os.path.exists(backup_file):
        print(f"Error: Backup file not found: {backup_file}")
        return

    if not os.path.exists(metadata_file):
        print(f"Error: Metadata file not found: {metadata_file}")
        return

    # Read metadata
    with open(metadata_file, "r") as f:
        metadata = json.load(f)

    # Check if file is encrypted (case-insensitive check)
    encryption_status = metadata.get("encryption", "").upper()
    if encryption_status != "ENCRYPTED":
        print(
            f"Error: Backup is not encrypted (encryption status: {metadata.get('encryption')})"
        )
        print("No decryption needed. You can decompress/restore the file directly.")
        return

    backup_id = metadata["backupId"]
    salt = base64.b64decode(metadata["encryptionSalt"])
    iv = base64.b64decode(metadata["encryptionIV"])

    # Generate output filename with decrypted_ prefix
    backup_dir = os.path.dirname(backup_file) or "."
    backup_name = os.path.basename(backup_file)
    output_file = os.path.join(backup_dir, f"decrypted_{backup_name}")

    # Derive encryption key using PBKDF2
    key_material = (master_key + backup_id).encode("utf-8")
    derived_key = PBKDF2(
        key_material, salt, dkLen=32, count=PBKDF2_ITERATIONS, hmac_hash_module=SHA256
    )

    try:
        with open(backup_file, "rb") as f_in, open(output_file, "wb") as f_out:
            # Read and validate header
            header = f_in.read(HEADER_LENGTH)

            # Validate magic bytes
            magic = header[:8]
            if magic != MAGIC_BYTES:
                raise ValueError(
                    f"Invalid magic bytes: expected {MAGIC_BYTES}, got {magic}"
                )

            # Decrypt chunks
            chunk_index = 0
            while True:
                # Read chunk length (4 bytes)
                length_bytes = f_in.read(4)
                if not length_bytes:
                    break

                chunk_length = struct.unpack(">I", length_bytes)[0]

                # Read encrypted chunk
                encrypted_chunk = f_in.read(chunk_length)
                if not encrypted_chunk:
                    break

                # Generate chunk nonce (base IV + chunk index)
                chunk_nonce = bytearray(iv)
                chunk_nonce[4:12] = struct.pack(">Q", chunk_index)

                # Create cipher for this chunk
                chunk_cipher = AES.new(derived_key, AES.MODE_GCM, nonce=bytes(chunk_nonce))

                # Decrypt chunk
                try:
                    decrypted_chunk = chunk_cipher.decrypt_and_verify(
                        encrypted_chunk[:-16],  # ciphertext
                        encrypted_chunk[-16:],  # auth tag
                    )
                except ValueError as e:
                    if "MAC check failed" in str(e):
                        print("\nError: Failed to decrypt backup (MAC check failed)")
                        print("This usually means:")
                        print("  - The master key is incorrect")
                        print("  - The backup file is corrupted")
                        print("  - The metadata doesn't match this backup file")
                        print(f"\nFailed at chunk {chunk_index}")
                        raise
                    raise

                # Write decrypted data
                f_out.write(decrypted_chunk)
                chunk_index += 1

        print(f"Successfully decrypted {chunk_index} chunks to {output_file}")

    except ValueError as e:
        # Clean up partial output file after files are closed
        if "MAC check failed" in str(e) and os.path.exists(output_file):
            os.remove(output_file)
        return


# Example usage:
if __name__ == "__main__":
    decrypt_backup(
        backup_file="./your-backup-file",             # <--- change this to your backup file
        metadata_file="./your-backup-file.metadata",  # <--- change this to your metadata file
        master_key="your-master-key-here",            # <--- change this to your master key
    )

安装所需依赖:

pip install pycryptodome

脚本用法:

  1. 把上面的脚本保存为文件(例如 decrypt_backup.py
  2. 修改文件末尾示例用法部分的参数
  3. 运行脚本:
python decrypt_backup.py

脚本会自动生成带 decrypted_ 前缀的输出文件。例如,备份文件是 backup-id.dump,解密后的文件就是 decrypted_backup-id.dump

恢复到数据库

解密之后,使用各数据库自带的工具恢复:

PostgreSQL

PostgreSQL 备份使用内置压缩,可以直接恢复:

本地数据库:

# Restore to local database
pg_restore -d your_database decrypted-backup.dump

远程数据库:

# Restore to remote database
pg_restore -h hostname -p 5432 -U username -d database_name decrypted-backup.dump

MySQL

MySQL 备份使用 zstd 5 级压缩,恢复之前必须先解压。

第 1 步:解压备份

使用 zstd 命令行工具或任何兼容的解压工具(7-Zip、PeaZip、 WinRAR 等):

# Decompress with zstd command-line tool
zstd -d decrypted-backup.sql.zst -o decrypted-backup.sql

# Or use graphical tools like 7-Zip, PeaZip, or WinRAR

第 2 步:恢复到数据库

本地数据库:

# Restore to local database
mysql your_database < decrypted-backup.sql

远程数据库:

# Restore to remote database
mysql -h hostname -P 3306 -u username -p database_name < decrypted-backup.sql

MariaDB

MariaDB 备份使用 zstd 5 级压缩,恢复之前必须先解压。

第 1 步:解压备份

使用 zstd 命令行工具或任何兼容的解压工具(7-Zip、PeaZip、 WinRAR 等):

# Decompress with zstd command-line tool
zstd -d decrypted-backup.sql.zst -o decrypted-backup.sql

# Or use graphical tools like 7-Zip, PeaZip, or WinRAR

第 2 步:恢复到数据库

本地数据库:

# Restore to local database
mariadb your_database < decrypted-backup.sql

远程数据库:

# Restore to remote database
mariadb -h hostname -P 3306 -u username -p database_name < decrypted-backup.sql

MongoDB

MongoDB 备份使用内置 gzip 压缩,可以直接恢复:

本地数据库:

# Restore to local database
mongorestore --archive=decrypted-backup.archive --gzip --db your_database

远程数据库:

# Restore to remote database
mongorestore --host hostname:27017 --username username --password password \
  --archive=decrypted-backup.archive --gzip --db database_name

遇到问题怎么办?

如果恢复过程中遇到任何问题:

  • 向 AI 求助。ChatGPT、Claude、Gemini 这类 AI 助手非常擅长解答压缩工具和数据库恢复方面的问题。描述一下 你的问题,它们就能一步步指导你。
  • 加入我们的 社区。我们的开发者和社区成员可以针对你的具体情况提供帮助。