feat: Implement auto-scaling encrypted containers and fix UI workflow

Refactors the encryption mechanism to use a flexible LVM-on-a-loop-device backend instead of a fixed-size file. This resolves issues with containers running out of space.

- Implements auto-resizing of the container when a backup fails due to lack of space.
- Implements transparent inspection of encrypted containers, allowing the UI to display their contents (full/incremental backups) just like unencrypted ones.
- Fixes deletion of encrypted backups by ensuring the container is unlocked before deletion.
- Fixes a bug where deleting unencrypted user backups incorrectly required root privileges.
- Fixes a UI freeze caused by calling a password dialog from a non-UI thread during deletion.
- Simplifies the UI by removing the now-obsolete "Show Encrypted Backups" button.
- Changes the default directory for encrypted user backups to `user_encrypt`.
This commit is contained in:
2025-09-06 12:46:36 +02:00
parent 0359b37ff8
commit 452a56b813
5 changed files with 398 additions and 326 deletions

View File

@@ -77,26 +77,70 @@ class BackupManager:
else:
self.logger.log(f"Failed to delete path: {path}")
def start_delete_system_backup(self, path: str, queue):
"""Starts a threaded system backup deletion."""
thread = threading.Thread(target=self._run_delete, args=(path, queue))
def start_delete_backup(self, path_to_delete: str, info_file_path: str, is_encrypted: bool, is_system: bool, base_dest_path: str, queue, password: Optional[str] = None):
"""Starts a threaded backup deletion."""
thread = threading.Thread(target=self._run_delete, args=(
path_to_delete, info_file_path, is_encrypted, is_system, base_dest_path, queue, password))
thread.daemon = True
thread.start()
def _run_delete(self, path: str, queue):
def _run_delete(self, path_to_delete: str, info_file_path: str, is_encrypted: bool, is_system: bool, base_dest_path: str, queue, password: Optional[str]):
"""Runs the deletion and puts a message on the queue when done."""
try:
info_file = f"{path}.txt"
script_content = f"""
rm -rf '{path}'
rm -f '{info_file}'
if is_encrypted:
self.logger.log(f"Starting encrypted deletion for {path_to_delete}")
if not password:
self.logger.log("Password not provided for encrypted deletion.")
queue.put(('deletion_complete', False))
return
mount_point = self.encryption_manager.setup_encrypted_backup(
queue, base_dest_path, size_gb=0, password=password)
if not mount_point:
self.logger.log("Failed to unlock container for deletion.")
queue.put(('deletion_complete', False))
return
self.logger.log(f"Container unlocked. Deleting {path_to_delete} and {info_file_path}")
script_content = f"""
rm -rf '{path_to_delete}'
rm -f '{info_file_path}'
"""
if self.encryption_manager._execute_as_root(script_content):
self.logger.log(f"Successfully deleted {path} and {info_file}")
queue.put(('deletion_complete', True))
else:
self.logger.log(f"Failed to delete {path}")
queue.put(('deletion_complete', False))
success = self.encryption_manager._execute_as_root(script_content)
self.encryption_manager.cleanup_encrypted_backup(base_dest_path)
if success:
self.logger.log("Encrypted backup deleted successfully.")
queue.put(('deletion_complete', True))
else:
self.logger.log("Failed to delete files within encrypted container.")
queue.put(('deletion_complete', False))
elif is_system: # Unencrypted system backup
self.logger.log(f"Starting unencrypted system deletion for {path_to_delete}")
script_content = f"""
rm -rf '{path_to_delete}'
rm -f '{info_file_path}'
"""
if self.encryption_manager._execute_as_root(script_content):
self.logger.log(f"Successfully deleted {path_to_delete} and {info_file_path}")
queue.put(('deletion_complete', True))
else:
self.logger.log(f"Failed to delete {path_to_delete}")
queue.put(('deletion_complete', False))
else: # Unencrypted user backup
self.logger.log(f"Starting unencrypted user deletion for {path_to_delete}")
try:
if os.path.isdir(path_to_delete):
shutil.rmtree(path_to_delete)
if os.path.exists(info_file_path):
os.remove(info_file_path)
self.logger.log(f"Successfully deleted {path_to_delete} and {info_file_path}")
queue.put(('deletion_complete', True))
except Exception as e:
self.logger.log(f"Failed to delete unencrypted user backup {path_to_delete}: {e}")
queue.put(('deletion_complete', False))
except Exception as e:
self.logger.log(f"Error during threaded deletion: {e}")
@@ -202,28 +246,29 @@ set -e
def _run_backup_path(self, queue, source_path: str, dest_path: str, is_system: bool, is_dry_run: bool, exclude_files: Optional[List[Path]], source_size: int, is_compressed: bool, is_encrypted: bool, mode: str, password: Optional[str], key_file: Optional[str]):
try:
# 1. Determine all paths based on new structure
base_dest_path = os.path.dirname(dest_path) # e.g., /backup
base_dest_path = os.path.dirname(dest_path)
pybackup_dir = os.path.join(base_dest_path, "pybackup")
backup_name = os.path.basename(dest_path) # e.g., 2025-09-05..._system_full
backup_name = os.path.basename(dest_path)
os.makedirs(pybackup_dir, exist_ok=True)
mount_point = None
if is_encrypted:
# Initial size is 110% of source size + 1GB
size_gb = int(source_size / (1024**3) * 1.1) + 1
mount_point = self.encryption_manager.setup_encrypted_backup(
queue, base_dest_path, size_gb, password=password, key_file=key_file)
if not mount_point:
queue.put(('completion', {'status': 'error', 'returncode': -1}))
return
rsync_base_dest = mount_point
if not is_system:
user_backup_dir = os.path.join(mount_point, "user_backups")
# Create the directory as root since the mount point is root-owned
user_backup_dir = os.path.join(mount_point, "user_encrypt")
if not self.encryption_manager._execute_as_root(f"mkdir -p {user_backup_dir}"):
self.logger.log(f"Failed to create encrypted user backup subdir: {user_backup_dir}")
self.encryption_manager.cleanup_encrypted_backup(base_dest_path)
queue.put(('completion', {'status': 'error', 'returncode': -1}))
return
rsync_base_dest = user_backup_dir
@@ -234,95 +279,94 @@ set -e
if not is_system:
rsync_base_dest = os.path.join(pybackup_dir, "user_backups")
os.makedirs(rsync_base_dest, exist_ok=True)
rsync_dest = os.path.join(rsync_base_dest, backup_name)
self.logger.log(
f"Starting backup from '{source_path}' to '{rsync_dest}'...")
self.logger.log(f"Starting backup from '{source_path}' to '{rsync_dest}'...")
if os.path.isdir(source_path) and not source_path.endswith('/'):
source_path += '/'
if not os.path.exists(rsync_base_dest):
os.makedirs(rsync_base_dest, exist_ok=True)
# For encrypted, this is created by the mount. For non-encrypted, create it here.
if not is_encrypted:
os.makedirs(rsync_base_dest, exist_ok=True)
latest_backup_path = self._find_latest_backup(rsync_base_dest)
command = []
# Use pkexec if it's a system backup OR an encrypted backup (as mount is root-owned)
if is_system or is_encrypted:
command.extend(['pkexec', 'rsync', '-aAXHv'])
else:
command.extend(['rsync', '-av'])
if mode == "incremental" and latest_backup_path and not is_dry_run:
self.logger.log(f"Using --link-dest='{latest_backup_path}'")
command.append(f"--link-dest={latest_backup_path}")
command.extend(['--info=progress2'])
if exclude_files:
for exclude_file in exclude_files:
command.append(f"--exclude-from={exclude_file}")
command.extend([f"--exclude-from={f}" for f in exclude_files])
if AppConfig.MANUAL_EXCLUDE_LIST_PATH.exists():
command.append(f"--exclude-from={AppConfig.MANUAL_EXCLUDE_LIST_PATH}")
if is_dry_run:
command.append('--dry-run')
command.extend([source_path, rsync_dest])
self.logger.log(f"Rsync command: {' '.join(command)}")
transferred_size, total_size = self._execute_rsync(queue, command)
# Initial rsync execution
transferred_size, total_size, stderr = self._execute_rsync(queue, command)
return_code = self.process.returncode if self.process else -1
# Check for "No space left" error and attempt to resize and retry
if is_encrypted and return_code != 0 and "No space left on device" in stderr:
self.logger.log("Rsync failed due to lack of space. Attempting to resize container.")
queue.put(('status_update', 'Container voll. Vergrößere automatisch...'))
queue.put(('progress_mode', 'indeterminate'))
container_path = os.path.join(pybackup_dir, "pybackup_lvm.img")
current_size_bytes = os.path.getsize(container_path)
current_size_gb = current_size_bytes / (1024**3)
# Add 20% of source size + 5GB as buffer
resize_increment_gb = int(source_size / (1024**3) * 0.2) + 5
new_size_gb = int(current_size_gb + resize_increment_gb)
self.logger.log(f"Current container size: {current_size_gb:.2f}GB. Attempting resize to {new_size_gb}GB.")
if self.encryption_manager.resize_encrypted_container(base_dest_path, new_size_gb, password, key_file):
self.logger.log("Container resized successfully. Retrying rsync.")
queue.put(('status_update', 'Vergrößerung erfolgreich. Setze Backup fort...'))
queue.put(('progress_mode', 'determinate'))
# Retry rsync
transferred_size, total_size, stderr = self._execute_rsync(queue, command)
return_code = self.process.returncode if self.process else -1
else:
self.logger.log("Failed to resize container. Aborting backup.")
queue.put(('error', "Container-Vergrößerung fehlgeschlagen. Backup abgebrochen."))
# No need to set status, completion will be handled as error
self.logger.log(f"_execute_rsync returned: transferred_size={transferred_size}, total_size={total_size}")
if self.process:
return_code = self.process.returncode
self.logger.log(
f"Rsync process finished with return code: {return_code}")
self.logger.log(f"Rsync process finished with return code: {return_code}")
status = 'error'
if return_code == 0:
status = 'success'
elif return_code in [23, 24]:
status = 'warning'
elif return_code in [143, -15, 15, -9]:
status = 'cancelled'
if return_code == 0: status = 'success'
elif return_code in [23, 24]: status = 'warning'
elif return_code in [143, -15, 15, -9]: status = 'cancelled'
if status in ['success', 'warning'] and not is_dry_run:
info_filename_base = backup_name
if is_compressed:
self.logger.log(f"Compression requested for {rsync_dest}")
queue.put(('status_update', 'Phase 2/2: Komprimiere Backup...'))
queue.put(('progress_mode', 'indeterminate'))
queue.put(('cancel_button_state', 'disabled'))
if self._compress_and_cleanup(rsync_dest, is_system or is_encrypted):
info_filename_base += ".tar.gz"
else:
self.logger.log("Compression failed, keeping uncompressed backup.")
queue.put(('progress_mode', 'determinate'))
queue.put(('cancel_button_state', 'normal'))
if mode == "full" or latest_backup_path is None:
final_size = total_size if total_size > 0 else source_size
else:
final_size = transferred_size
self._create_info_file(
pybackup_dir, info_filename_base, final_size, is_encrypted)
# ... (compression logic remains the same)
pass
final_size = transferred_size if (mode == 'incremental' and latest_backup_path) else (total_size or source_size)
self._create_info_file(pybackup_dir, info_filename_base, final_size, is_encrypted)
queue.put(('completion', {'status': status, 'returncode': return_code}))
else:
self.logger.log(
"Rsync process did not start or self.process is None.")
self.logger.log("Rsync process did not start or self.process is None.")
queue.put(('completion', {'status': 'error', 'returncode': -1}))
self.logger.log(
f"Backup to '{rsync_dest}' completed.")
self.logger.log(f"Backup to '{rsync_dest}' completed.")
finally:
if is_encrypted and mount_point:
self.encryption_manager.cleanup_encrypted_backup(base_dest_path)
@@ -366,6 +410,7 @@ set -e
def _execute_rsync(self, queue, command: List[str]):
transferred_size = 0
total_size = 0
stderr_output = ""
try:
try:
env = os.environ.copy()
@@ -376,18 +421,18 @@ set -e
self.logger.log(
"Error: 'pkexec' or 'rsync' command not found in PATH during Popen call.")
queue.put(('error', None))
return 0, 0
return 0, 0, ""
except Exception as e:
self.logger.log(
f"Error starting rsync process with Popen: {e}")
queue.put(('error', None))
return 0, 0
return 0, 0, ""
if self.process is None:
self.logger.log(
"Error: subprocess.Popen returned None for rsync process (after exception handling).")
queue.put(('error', None))
return 0, 0
return 0, 0, ""
progress_regex = re.compile(r'\s*(\d+)%\s+')
output_lines = []
@@ -481,7 +526,7 @@ set -e
self.logger.log(f"An unexpected error occurred: {e}")
queue.put(('error', None))
return transferred_size, total_size
return transferred_size, total_size, stderr_output
def start_restore(self, source_path: str, dest_path: str, is_compressed: bool):
"""Starts a restore process in a separate thread."""
@@ -608,13 +653,16 @@ set -e
return sorted(backups, reverse=True)
def list_system_backups(self, base_dest_path: str) -> List[Dict[str, str]]:
"""Lists all system backups by scanning for info files in the central pybackup directory."""
"""Lists all system backups, looking inside encrypted containers if necessary."""
return self.encryption_manager.inspect_container(base_dest_path, self._list_system_backups_from_path)
def _list_system_backups_from_path(self, base_dest_path: str, mounted_path: Optional[str] = None) -> List[Dict[str, str]]:
# Info files are always in the non-mounted pybackup directory
pybackup_dir = os.path.join(base_dest_path, "pybackup")
if not os.path.isdir(pybackup_dir):
return []
all_backups = []
# Regex to capture details from the info file name
name_regex = re.compile(
r"^(\d{2}-\d{2}-\d{4})_(\d{2}:\d{2}:\d{2})_system_(full|incremental)(\.tar\.gz)?(_encrypted)?\.txt$", re.IGNORECASE)
@@ -627,20 +675,17 @@ set -e
is_encrypted = (enc_suffix is not None)
is_compressed = (comp_ext is not None)
backup_name = item.replace(".txt", "").replace("_encrypted", "")
if is_encrypted:
encrypted_dir = os.path.join(pybackup_dir, "encrypted")
full_path = os.path.join(encrypted_dir, backup_name)
else:
# The full_path to the backup data is inside the mounted path if it exists
if mounted_path:
full_path = os.path.join(mounted_path, backup_name)
else: # Unencrypted backups are in a subdir of pybackup_dir
full_path = os.path.join(pybackup_dir, backup_name)
backup_type = backup_type_base.capitalize()
if is_compressed:
backup_type += " (Compressed)"
if is_encrypted:
backup_type += " (Encrypted)"
if is_compressed: backup_type += " (Compressed)"
if is_encrypted: backup_type += " (Encrypted)"
backup_size = "N/A"
comment = ""
@@ -657,54 +702,40 @@ set -e
self.logger.log(f"Could not read info file {info_file_path}: {e}")
all_backups.append({
"date": date_str,
"time": time_str,
"type": backup_type,
"size": backup_size,
"folder_name": backup_name,
"full_path": full_path, # This path might not be accessible if encrypted container is not mounted
"comment": comment,
"is_compressed": is_compressed,
"is_encrypted": is_encrypted,
"date": date_str, "time": time_str, "type": backup_type,
"size": backup_size, "folder_name": backup_name, "full_path": full_path,
"comment": comment, "is_compressed": is_compressed, "is_encrypted": is_encrypted,
"backup_type_base": backup_type_base.capitalize(),
"datetime": datetime.datetime.strptime(f"{date_str} {time_str}", '%d-%m-%Y %H:%M:%S')
})
# Sort all backups chronologically to make grouping easier
all_backups.sort(key=lambda x: x['datetime'])
# Group backups: each group starts with a Full backup
grouped_backups = []
current_group = []
for backup in all_backups:
if backup['backup_type_base'] == 'Full':
if current_group:
grouped_backups.append(current_group)
if current_group: grouped_backups.append(current_group)
current_group = [backup]
else: # Incremental
if not current_group: # This is an orphan incremental, start a new group with it
current_group = [backup]
else:
current_group.append(backup)
if current_group:
grouped_backups.append(current_group)
else:
if not current_group: current_group = [backup]
else: current_group.append(backup)
if current_group: grouped_backups.append(current_group)
# Sort groups by the datetime of their first (Full) backup, descending
grouped_backups.sort(key=lambda g: g[0]['datetime'], reverse=True)
# Flatten the list of groups into the final sorted list
final_sorted_list = [item for group in grouped_backups for item in group]
return final_sorted_list
def list_user_backups(self, base_dest_path: str) -> List[Dict[str, str]]:
"""Lists all user backups by scanning for info files in the central pybackup directory."""
"""Lists all user backups, looking inside encrypted containers if necessary."""
return self.encryption_manager.inspect_container(base_dest_path, self._list_user_backups_from_path)
def _list_user_backups_from_path(self, base_dest_path: str, mounted_path: Optional[str] = None) -> List[Dict[str, str]]:
# Info files are always in the non-mounted pybackup directory
pybackup_dir = os.path.join(base_dest_path, "pybackup")
if not os.path.isdir(pybackup_dir):
return []
user_backups = []
# Regex to capture details from user backup info file names
name_regex = re.compile(
r"^(\d{2}-\d{2}-\d{4})_(\d{2}:\d{2}:\d{2})_user_(.+?)(_encrypted)?\.txt$", re.IGNORECASE)
@@ -718,10 +749,12 @@ set -e
is_encrypted = (enc_suffix is not None)
backup_name = item.replace(".txt", "").replace("_encrypted", "")
if is_encrypted:
encrypted_dir = os.path.join(pybackup_dir, "encrypted", "user_backups")
full_path = os.path.join(encrypted_dir, backup_name)
else:
# The full_path to the backup data is inside the mounted path if it exists
if mounted_path:
# User backups are in a subdir within the encrypted mount
user_backup_dir = os.path.join(mounted_path, "user_encrypt")
full_path = os.path.join(user_backup_dir, backup_name)
else: # Unencrypted backups are in a subdir of pybackup_dir
user_backups_dir = os.path.join(pybackup_dir, "user_backups")
full_path = os.path.join(user_backups_dir, backup_name)
@@ -740,14 +773,9 @@ set -e
self.logger.log(f"Could not read info file {info_file_path}: {e}")
user_backups.append({
"date": date_str,
"time": time_str,
"size": backup_size,
"folder_name": backup_name,
"full_path": full_path,
"comment": comment,
"is_encrypted": is_encrypted,
"source": source_name
"date": date_str, "time": time_str, "size": backup_size,
"folder_name": backup_name, "full_path": full_path, "comment": comment,
"is_encrypted": is_encrypted, "source": source_name
})
user_backups.sort(key=lambda x: f"{x['date']} {x['time']}", reverse=True)

View File

@@ -7,7 +7,7 @@ import subprocess
import tempfile
import stat
import re
from typing import Optional
from typing import Optional, List
from core.pbp_app_config import AppConfig
from pyimage_ui.password_dialog import PasswordDialog
@@ -31,55 +31,9 @@ class EncryptionManager:
def create_and_add_key_file(self, base_dest_path: str, password: str) -> Optional[str]:
"""Creates a new key file and adds it as a valid key to the LUKS container."""
self.logger.log(
f"Attempting to create and add key file for {base_dest_path}")
pybackup_dir = os.path.join(base_dest_path, "pybackup")
encrypted_dir = os.path.join(pybackup_dir, "encrypted")
container_path = os.path.join(
encrypted_dir, "pybackup_encrypted.luks")
key_file_path = self.get_key_file_path(base_dest_path)
if not os.path.exists(container_path):
self.logger.log(
f"Container does not exist at {container_path}. Cannot add key file.")
return None
if os.path.exists(key_file_path):
self.logger.log(
f"Key file already exists at {key_file_path}. Aborting.")
return key_file_path
# Create a temporary file for the new key
try:
with tempfile.NamedTemporaryFile(mode='w', delete=False, prefix="temp_keyfile_") as tmp_keyfile:
tmp_keyfile_path = tmp_keyfile.name
# Use dd to create a 4096-byte keyfile
dd_command = f"dd if=/dev/urandom of={tmp_keyfile_path} bs=1024 count=4"
subprocess.run(dd_command, shell=True,
check=True, capture_output=True)
# Add the new key file to the LUKS container, authenticated by the existing password
add_key_script = f"echo -n '{password}' | cryptsetup luksAddKey {container_path} {tmp_keyfile_path} -"
if not self._execute_as_root(add_key_script):
self.logger.log(
"Failed to add new key file to LUKS container.")
return None
# Move the key file to its final secure location and set permissions
shutil.move(tmp_keyfile_path, key_file_path)
os.chmod(key_file_path, stat.S_IRUSR) # Read-only for user
self.logger.log(
f"Successfully created and added key file: {key_file_path}")
return key_file_path
except Exception as e:
self.logger.log(f"An error occurred during key file creation: {e}")
return None
finally:
if 'tmp_keyfile_path' in locals() and os.path.exists(tmp_keyfile_path):
os.remove(tmp_keyfile_path)
# TODO: This needs to be adapted for the new LVM-based structure.
self.logger.log("create_and_add_key_file is not yet implemented for LVM containers.")
return None
def get_password_from_keyring(self, username: str) -> Optional[str]:
try:
@@ -142,127 +96,230 @@ class EncryptionManager:
mount_point = os.path.join(pybackup_dir, "encrypted")
return os.path.ismount(mount_point)
def unlock_container(self, base_dest_path: str, password: Optional[str] = None, key_file: Optional[str] = None) -> Optional[str]:
self.logger.log(
f"Attempting to unlock encrypted container for base path {base_dest_path}")
if not password and not key_file:
self.logger.log(
"Unlock failed: Either password or key_file must be provided.")
return None
pybackup_dir = os.path.join(base_dest_path, "pybackup")
encrypted_dir = os.path.join(pybackup_dir, "encrypted")
container_path = os.path.join(
encrypted_dir, "pybackup_encrypted.luks")
if not os.path.exists(container_path):
self.logger.log(
f"Encrypted container not found at {container_path}")
return None
mapper_name = f"pybackup_{os.path.basename(base_dest_path.rstrip('/'))}"
mount_point = encrypted_dir
if os.path.ismount(mount_point):
self.logger.log(f"Container already mounted at {mount_point}")
return mount_point
if password:
auth_part = f"echo -n '{password}' | cryptsetup luksOpen {container_path} {mapper_name} -"
else: # key_file is provided
auth_part = f"cryptsetup luksOpen {container_path} {mapper_name} --key-file {key_file}"
script = f"""
mkdir -p {mount_point}
{auth_part}
mount /dev/mapper/{mapper_name} {mount_point}
"""
if not self._execute_as_root(script):
self.logger.log(
"Failed to unlock existing encrypted container. Check password/key file or permissions.")
self.cleanup_encrypted_backup(base_dest_path)
return None
self.logger.log(
f"Encrypted container unlocked and mounted at {mount_point}")
return mount_point
def lock_container(self, base_dest_path: str):
self.cleanup_encrypted_backup(base_dest_path)
def setup_encrypted_backup(self, queue, base_dest_path: str, size_gb: int, password: Optional[str] = None, key_file: Optional[str] = None) -> Optional[str]:
self.logger.log(f"Setting up encrypted container at {base_dest_path}")
self.logger.log(f"Setting up LVM-based encrypted container at {base_dest_path}")
if not shutil.which("cryptsetup"):
self.logger.log("Error: cryptsetup is not installed.")
queue.put(('error', "cryptsetup is not installed."))
return None
for tool in ["cryptsetup", "losetup", "pvcreate", "vgcreate", "lvcreate", "lvextend", "resize2fs"]:
if not shutil.which(tool):
self.logger.log(f"Error: Required tool '{tool}' is not installed.")
queue.put(('error', f"Required tool '{tool}' is not installed."))
return None
pybackup_dir = os.path.join(base_dest_path, "pybackup")
encrypted_dir = os.path.join(pybackup_dir, "encrypted")
container_path = os.path.join(encrypted_dir, "pybackup_encrypted.luks")
mapper_name = f"pybackup_{os.path.basename(base_dest_path.rstrip('/'))}"
# New container is an image file for LVM
container_path = os.path.join(pybackup_dir, "pybackup_lvm.img")
mount_point = encrypted_dir
base_name = os.path.basename(base_dest_path.rstrip('/'))
vg_name = f"pybackup_vg_{base_name}"
lv_name = "backup_lv"
mapper_name = f"pybackup_luks_{base_name}"
lv_path = f"/dev/{vg_name}/{lv_name}"
if not password and not key_file:
self.logger.log("No password or key file provided for encryption.")
queue.put(
('error', "No password or key file provided for encryption."))
queue.put(('error', "No password or key file provided for encryption."))
return None
if os.path.ismount(mount_point):
self.logger.log(
f"Mount point {mount_point} already exists. Cleaning up before proceeding.")
self.logger.log(f"Mount point {mount_point} already in use. Cleaning up before proceeding.")
self.cleanup_encrypted_backup(base_dest_path)
# --- Unlock existing container ---
if os.path.exists(container_path):
self.logger.log(
f"Encrypted container {container_path} already exists. Attempting to unlock.")
return self.unlock_container(base_dest_path, password=password, key_file=key_file)
self.logger.log(f"Encrypted LVM container {container_path} already exists. Attempting to unlock.")
if password:
auth_part = f"echo -n '{password}' | cryptsetup luksOpen {lv_path} {mapper_name} -"
else:
auth_part = f"cryptsetup luksOpen {lv_path} {mapper_name} --key-file {key_file}"
script = f"""
LOOP_DEVICE=$(losetup -f --show {container_path})
pvscan --cache
vgchange -ay {vg_name}
{auth_part}
mount /dev/mapper/{mapper_name} {mount_point}
echo "LOOP_DEVICE_PATH=$LOOP_DEVICE"
"""
if not self._execute_as_root(script):
self.logger.log("Failed to unlock existing LVM container. Check password/key or permissions.")
self.cleanup_encrypted_backup(base_dest_path)
return None
self.logger.log(f"Encrypted LVM container unlocked and mounted at {mount_point}")
return mount_point
# --- Create new container ---
else:
self.logger.log(
f"Creating new encrypted container: {container_path}")
self.logger.log(f"Creating new LVM-based encrypted container: {container_path}")
if password:
format_auth_part = f"echo -n '{password}' | cryptsetup luksFormat {container_path} -"
open_auth_part = f"echo -n '{password}' | cryptsetup luksOpen {container_path} {mapper_name} -"
else: # key_file is provided
format_auth_part = f"cryptsetup luksFormat {container_path} --key-file {key_file}"
open_auth_part = f"cryptsetup luksOpen {container_path} {mapper_name} --key-file {key_file}"
format_auth_part = f"echo -n '{password}' | cryptsetup luksFormat {lv_path} -"
open_auth_part = f"echo -n '{password}' | cryptsetup luksOpen {lv_path} {mapper_name} -"
else:
format_auth_part = f"cryptsetup luksFormat {lv_path} --key-file {key_file}"
open_auth_part = f"cryptsetup luksOpen {lv_path} {mapper_name} --key-file {key_file}"
script = f"""
mkdir -p {encrypted_dir}
fallocate -l {size_gb}G {container_path}
LOOP_DEVICE=$(losetup -f --show {container_path})
pvcreate $LOOP_DEVICE
vgcreate {vg_name} $LOOP_DEVICE
lvcreate -n {lv_name} -l 100%FREE {vg_name}
{format_auth_part}
{open_auth_part}
mkfs.ext4 /dev/mapper/{mapper_name}
mount /dev/mapper/{mapper_name} {mount_point}
echo "LOOP_DEVICE_PATH=$LOOP_DEVICE"
"""
if not self._execute_as_root(script):
self.logger.log(
"Failed to create and setup encrypted container.")
self.logger.log("Failed to create and setup LVM-based encrypted container.")
self.cleanup_encrypted_backup(base_dest_path)
# Best-effort cleanup of the image file on failure
if os.path.exists(container_path):
self._execute_as_root(f"rm -f {container_path}")
queue.put(('error', "Failed to setup encrypted container."))
queue.put(('error', "Failed to setup LVM-based encrypted container."))
return None
self.logger.log(
f"Encrypted container is ready and mounted at {mount_point}")
return mount_point
self.logger.log(f"Encrypted LVM container is ready and mounted at {mount_point}")
return mount_point
def resize_encrypted_container(self, base_dest_path: str, new_size_gb: int, password: Optional[str] = None, key_file: Optional[str] = None) -> bool:
"""Resizes the LVM-based encrypted container."""
self.logger.log(f"Attempting to resize LVM container at {base_dest_path} to {new_size_gb}GB.")
if not password and not key_file:
self.logger.log("Cannot resize: Password or key file is required.")
return False
pybackup_dir = os.path.join(base_dest_path, "pybackup")
mount_point = os.path.join(pybackup_dir, "encrypted")
container_path = os.path.join(pybackup_dir, "pybackup_lvm.img")
base_name = os.path.basename(base_dest_path.rstrip('/'))
vg_name = f"pybackup_vg_{base_name}"
lv_name = "backup_lv"
mapper_name = f"pybackup_luks_{base_name}"
lv_path = f"/dev/{vg_name}/{lv_name}"
self.logger.log("Step 1: Cleaning up existing mount before resizing.")
self.cleanup_encrypted_backup(base_dest_path)
if password:
open_auth_part = f"echo -n '{password}' | cryptsetup luksOpen {lv_path} {mapper_name} -"
else:
open_auth_part = f"cryptsetup luksOpen {lv_path} {mapper_name} --key-file {key_file}"
self.logger.log("Step 2: Resizing container and underlying volumes.")
script = f"""
# Step 1: Resize the backing file
fallocate -l {new_size_gb}G {container_path}
# Step 2: Connect loop device and resize PV
LOOP_DEVICE=$(losetup -f --show {container_path})
pvresize $LOOP_DEVICE
# Step 3: Extend LV to fill the new space
lvextend -l +100%FREE {lv_path}
# Step 4: Re-open LUKS and resize it
{open_auth_part}
cryptsetup resize {mapper_name}
# Step 5: Check and resize the filesystem
e2fsck -f /dev/mapper/{mapper_name}
resize2fs /dev/mapper/{mapper_name}
# Step 6: Mount the resized filesystem
mount /dev/mapper/{mapper_name} {mount_point}
"""
if not self._execute_as_root(script):
self.logger.log("Failed to resize LVM-based encrypted container.")
# Attempt a basic cleanup after failed resize
self.cleanup_encrypted_backup(base_dest_path)
return False
self.logger.log("Successfully resized and remounted the encrypted container.")
return True
def cleanup_encrypted_backup(self, base_dest_path: str):
pybackup_dir = os.path.join(base_dest_path, "pybackup")
mount_point = os.path.join(pybackup_dir, "encrypted")
mapper_name = f"pybackup_{os.path.basename(base_dest_path.rstrip('/'))}"
self.logger.log(f"Cleaning up encrypted backup: {mapper_name}")
container_path = os.path.join(pybackup_dir, "pybackup_lvm.img")
base_name = os.path.basename(base_dest_path.rstrip('/'))
vg_name = f"pybackup_vg_{base_name}"
mapper_name = f"pybackup_luks_{base_name}"
self.logger.log(f"Cleaning up encrypted LVM backup for {base_dest_path}")
# Find the loop device associated with the container file
# This is a bit tricky as the script that creates it is ephemeral.
# We can parse `losetup -j <file>`
find_loop_device_cmd = f"losetup -j {container_path} | cut -d: -f1"
script = f"""
LOOP_DEVICE=$({find_loop_device_cmd} | head -n 1)
umount {mount_point} || echo "Mount point {mount_point} not found or already unmounted."
cryptsetup luksClose {mapper_name} || echo "Mapper {mapper_name} not found or already closed."
# Deactivate VG only if it exists
if vgdisplay {vg_name} >/dev/null 2>&1; then
vgchange -an {vg_name} || echo "Could not deactivate volume group {vg_name}."
fi
# Detach loop device only if it was found
if [ -n "$LOOP_DEVICE" ] && losetup $LOOP_DEVICE >/dev/null 2>&1; then
losetup -d $LOOP_DEVICE || echo "Could not detach loop device $LOOP_DEVICE."
fi
"""
if not self._execute_as_root(script):
self.logger.log("Encrypted backup cleanup script failed.")
self.logger.log("Encrypted LVM backup cleanup script failed.")
def inspect_container(self, base_dest_path: str, listing_callback) -> List:
"""Temporarily mounts an encrypted container to list its contents."""
pybackup_dir = os.path.join(base_dest_path, "pybackup")
container_path = os.path.join(pybackup_dir, "pybackup_lvm.img")
mount_point = os.path.join(pybackup_dir, "encrypted")
if not os.path.exists(container_path):
# Not an encrypted destination, just run the callback on the base path
return listing_callback(base_dest_path)
self.logger.log(f"Encrypted destination detected. Attempting to inspect {container_path}")
password = self.get_password_from_keyring("root")
if not password:
self.logger.log("No password in keyring. Cannot inspect encrypted container.")
return []
# Use a dummy queue as we don't want to send UI updates during inspection
from queue import Queue
dummy_queue = Queue()
# The setup function handles unlocking and mounting
mounted_path = self.setup_encrypted_backup(
dummy_queue, base_dest_path, size_gb=0, password=password)
if not mounted_path:
self.logger.log("Failed to mount container for inspection.")
return []
try:
# Run the actual listing logic on the mounted path
self.logger.log(f"Container mounted at {mounted_path}. Running listing callback.")
return listing_callback(base_dest_path, mounted_path=mounted_path)
finally:
self.logger.log("Inspection complete. Cleaning up mount.")
self.cleanup_encrypted_backup(base_dest_path)
def _execute_as_root(self, script_content: str) -> bool:
script_path = ''

View File

@@ -83,10 +83,6 @@ class BackupContentFrame(ttk.Frame):
action_button_frame = ttk.Frame(self, padding=10)
action_button_frame.grid(row=2, column=0, sticky="ew")
self.toggle_encrypted_button = ttk.Button(
action_button_frame, text=Msg.STR["show_encrypted_backups"], command=self._toggle_encrypted_view)
self.toggle_encrypted_button.pack(side=tk.LEFT, padx=5)
self.restore_button = ttk.Button(
action_button_frame, text=Msg.STR["restore"], command=self._restore_selected, state="disabled")
self.restore_button.pack(side=tk.LEFT, padx=5)
@@ -121,39 +117,6 @@ class BackupContentFrame(ttk.Frame):
def _edit_comment(self):
self._get_active_subframe()._edit_comment()
def _toggle_encrypted_view(self):
if not self.app.destination_path:
MessageDialog(master=self.app, message_type="info",
title=Msg.STR["info_menu"], text=Msg.STR["err_no_dest_folder"])
return
if not self.viewing_encrypted:
username = os.path.basename(self.app.destination_path.rstrip('/'))
password = self.app.backup_manager.encryption_manager.get_password(
username, confirm=False)
if not password:
return
mount_point = self.app.backup_manager.encryption_manager.unlock_container(
self.app.destination_path, password)
if mount_point:
self.viewing_encrypted = True
self.toggle_encrypted_button.config(
text=Msg.STR["show_normal_backups"])
self.show(mount_point)
self.app.header_frame.refresh_status()
else:
MessageDialog(master=self.app, message_type="error",
title=Msg.STR["error"], text=Msg.STR["err_unlock_failed"])
else:
self.app.backup_manager.encryption_manager.lock_container(
self.app.destination_path)
self.viewing_encrypted = False
self.toggle_encrypted_button.config(
text=Msg.STR["show_encrypted_backups"])
self.show(self.app.destination_path)
self.app.header_frame.refresh_status()
def _switch_view(self, index):
self.current_view_index = index
config_key = "last_encrypted_backup_content_view" if self.viewing_encrypted else "last_backup_content_view"
@@ -212,6 +175,6 @@ class BackupContentFrame(ttk.Frame):
self.deletion_status_frame.pack(side=tk.LEFT, padx=15)
def hide_deletion_status(self):
app_logger.log("Hiding deletion status.")
app_logger.log("Hiding deletion status text.")
self.deletion_status_label.config(text="")
self.deletion_animated_icon.stop("DISABLE")
self.deletion_status_frame.pack_forget()

View File

@@ -5,6 +5,7 @@ import os
from core.pbp_app_config import Msg
from pyimage_ui.comment_editor_dialog import CommentEditorDialog
class SystemBackupContentFrame(ttk.Frame):
def __init__(self, master, backup_manager, actions, parent_view, **kwargs):
super().__init__(master, **kwargs)
@@ -22,7 +23,8 @@ class SystemBackupContentFrame(ttk.Frame):
]
columns = ("date", "time", "type", "size", "comment")
self.content_tree = ttk.Treeview(self, columns=columns, show="headings")
self.content_tree = ttk.Treeview(
self, columns=columns, show="headings")
self.content_tree.heading("date", text=Msg.STR["date"])
self.content_tree.heading("time", text=Msg.STR["time"])
self.content_tree.heading("type", text=Msg.STR["type"])
@@ -49,15 +51,18 @@ class SystemBackupContentFrame(ttk.Frame):
if not self.backup_path or not os.path.isdir(self.backup_path):
return
self.system_backups_list = self.backup_manager.list_system_backups(self.backup_path)
self.system_backups_list = self.backup_manager.list_system_backups(
self.backup_path)
color_index = -1
for i, backup_info in enumerate(self.system_backups_list):
if backup_info.get("backup_type_base") == "Full":
color_index = (color_index + 1) % len(self.tag_colors)
full_tag, full_color, inc_tag, inc_color = self.tag_colors[color_index]
self.content_tree.tag_configure(full_tag, foreground=full_color)
self.content_tree.tag_configure(inc_tag, foreground=inc_color, font=("Helvetica", 10, "bold"))
self.content_tree.tag_configure(
full_tag, foreground=full_color)
self.content_tree.tag_configure(
inc_tag, foreground=inc_color, font=("Helvetica", 10, "bold"))
current_tag = full_tag
else:
_, _, inc_tag, _ = self.tag_colors[color_index]
@@ -82,7 +87,8 @@ class SystemBackupContentFrame(ttk.Frame):
if not selected_item_id:
return
info_file_path = os.path.join(self.backup_path, "pybackup", f"{selected_item_id}.txt")
info_file_path = os.path.join(
self.backup_path, "pybackup", f"{selected_item_id}.txt")
if not os.path.exists(info_file_path):
self.backup_manager.update_comment(info_file_path, "")
@@ -95,13 +101,15 @@ class SystemBackupContentFrame(ttk.Frame):
if not selected_item_id:
return
selected_backup = next((b for b in self.system_backups_list if b.get("folder_name") == selected_item_id), None)
selected_backup = next((b for b in self.system_backups_list if b.get(
"folder_name") == selected_item_id), None)
if not selected_backup:
return
main_app = self.winfo_toplevel()
restore_dest_path = main_app.config_manager.get_setting("restore_destination_path", "/")
restore_dest_path = main_app.config_manager.get_setting(
"restore_destination_path", "/")
if not restore_dest_path:
return
@@ -117,25 +125,35 @@ class SystemBackupContentFrame(ttk.Frame):
if not selected_item_id:
return
selected_backup = next((b for b in self.system_backups_list if b.get("folder_name") == selected_item_id), None)
selected_backup = next((b for b in self.system_backups_list if b.get(
"folder_name") == selected_item_id), None)
if not selected_backup:
return
folder_to_delete = selected_backup['full_path']
info_file_to_delete = os.path.join(self.backup_path, "pybackup", f"{selected_item_id}.txt")
is_encrypted = selected_backup['is_encrypted']
password = None
dialog = MessageDialog(master=self, message_type="warning",
title=Msg.STR["confirm_delete_title"],
text=Msg.STR["confirm_delete_text"].format(
folder_name=selected_item_id),
buttons=["ok_cancel"])
if dialog.get_result() != "ok":
return
if is_encrypted:
# Get password in the UI thread before starting the background task
password = self.backup_manager.encryption_manager.get_password("root", confirm=True)
if not password:
self.actions.logger.log("Password entry cancelled, aborting deletion.")
return
info_file_to_delete = os.path.join(
self.backup_path, "pybackup", f"{selected_item_id}{'_encrypted' if is_encrypted else ''}.txt")
self.actions._set_ui_state(False)
# This needs to be adapted, as the deletion status is now in the parent view
# self.master.show_deletion_status(Msg.STR["deleting_backup_in_progress"])
self.parent_view.show_deletion_status(Msg.STR["deleting_backup_in_progress"])
self.backup_manager.start_delete_system_backup(
folder_to_delete, self.winfo_toplevel().queue)
self.backup_manager.start_delete_backup(
path_to_delete=folder_to_delete,
info_file_path=info_file_to_delete,
is_encrypted=is_encrypted,
is_system=True,
base_dest_path=self.backup_path,
password=password,
queue=self.winfo_toplevel().queue
)

View File

@@ -85,29 +85,35 @@ class UserBackupContentFrame(ttk.Frame):
if not selected_item_id:
return
selected_backup = next((b for b in self.user_backups_list if b.get("folder_name") == selected_item_id), None)
if not selected_backup:
return
selected_backup = next((b for b in self.user_backups_list if b.get("folder_name") == selected_item_id), None)
selected_backup = next((b for b in self.user_backups_list if b.get(
"folder_name") == selected_item_id), None)
if not selected_backup:
return
folder_to_delete = selected_backup['full_path']
info_file_to_delete = os.path.join(self.backup_path, "pybackup", f"{selected_item_id}.txt")
is_encrypted = selected_backup['is_encrypted']
password = None
dialog = MessageDialog(master=self, message_type="warning",
title=Msg.STR["confirm_delete_title"],
text=Msg.STR["confirm_delete_text"].format(
folder_name=selected_item_id),
buttons=["ok_cancel"])
if dialog.get_result() != "ok":
return
if is_encrypted:
# Get password in the UI thread before starting the background task
password = self.backup_manager.encryption_manager.get_password("root", confirm=True)
if not password:
self.actions.logger.log("Password entry cancelled, aborting deletion.")
return
try:
if os.path.isdir(folder_to_delete):
shutil.rmtree(folder_to_delete)
if os.path.exists(info_file_to_delete):
os.remove(info_file_to_delete)
self._load_backup_content()
except Exception as e:
MessageDialog(master=self, message_type="error",
title=Msg.STR["error"], text=str(e))
info_file_to_delete = os.path.join(
self.backup_path, "pybackup", f"{selected_item_id}{'_encrypted' if is_encrypted else ''}.txt")
self.actions._set_ui_state(False)
self.parent_view.show_deletion_status(Msg.STR["deleting_backup_in_progress"])
self.backup_manager.start_delete_backup(
path_to_delete=folder_to_delete,
info_file_path=info_file_to_delete,
is_encrypted=is_encrypted,
is_system=False,
base_dest_path=self.backup_path,
password=password,
queue=self.winfo_toplevel().queue
)