add lvm for img encrypt zo automatic resize for new encrypt backup
This commit is contained in:
@@ -4,6 +4,8 @@ import threading
|
||||
import re
|
||||
import signal
|
||||
import datetime
|
||||
import math
|
||||
import shutil
|
||||
from typing import Optional, List, Dict, Any
|
||||
from pathlib import Path
|
||||
from crontab import CronTab
|
||||
@@ -33,42 +35,52 @@ class BackupManager:
|
||||
def _inhibit_screensaver(self):
|
||||
"""Prevents the screensaver and auto-suspend during a backup."""
|
||||
if not shutil.which("gdbus"):
|
||||
self.logger.log("gdbus command not found, cannot inhibit screensaver.")
|
||||
self.logger.log(
|
||||
"gdbus command not found, cannot inhibit screensaver.")
|
||||
return
|
||||
|
||||
try:
|
||||
self.logger.log("Attempting to inhibit screensaver and power management.")
|
||||
self.logger.log(
|
||||
"Attempting to inhibit screensaver and power management.")
|
||||
command = [
|
||||
"gdbus", "call", "--session", "--dest", "org.freedesktop.ScreenSaver",
|
||||
"--object-path", "/org/freedesktop/ScreenSaver",
|
||||
"--method", "org.freedesktop.ScreenSaver.Inhibit",
|
||||
"Py-Backup", "Backup in progress"
|
||||
]
|
||||
result = subprocess.run(command, capture_output=True, text=True, check=True)
|
||||
result = subprocess.run(
|
||||
command, capture_output=True, text=True, check=True)
|
||||
# Output is like "(uint32 12345,)", we need to extract the number.
|
||||
match = re.search(r'uint32\s+(\d+)', result.stdout)
|
||||
if match:
|
||||
self.inhibit_cookie = int(match.group(1))
|
||||
self.logger.log(f"Successfully inhibited screensaver with cookie {self.inhibit_cookie}")
|
||||
self.logger.log(
|
||||
f"Successfully inhibited screensaver with cookie {self.inhibit_cookie}")
|
||||
else:
|
||||
self.logger.log(f"Could not parse inhibit cookie from gdbus output: {result.stdout}")
|
||||
self.logger.log(
|
||||
f"Could not parse inhibit cookie from gdbus output: {result.stdout}")
|
||||
except FileNotFoundError:
|
||||
self.logger.log("gdbus command not found, cannot inhibit screensaver.")
|
||||
self.logger.log(
|
||||
"gdbus command not found, cannot inhibit screensaver.")
|
||||
except subprocess.CalledProcessError as e:
|
||||
self.logger.log(f"Failed to inhibit screensaver. D-Bus call failed: {e.stderr}")
|
||||
self.logger.log(
|
||||
f"Failed to inhibit screensaver. D-Bus call failed: {e.stderr}")
|
||||
except Exception as e:
|
||||
self.logger.log(f"An unexpected error occurred while inhibiting screensaver: {e}")
|
||||
self.logger.log(
|
||||
f"An unexpected error occurred while inhibiting screensaver: {e}")
|
||||
|
||||
def _uninhibit_screensaver(self):
|
||||
"""Releases the screensaver and auto-suspend lock."""
|
||||
if self.inhibit_cookie is None:
|
||||
return
|
||||
if not shutil.which("gdbus"):
|
||||
self.logger.log("gdbus command not found, cannot uninhibit screensaver.")
|
||||
self.logger.log(
|
||||
"gdbus command not found, cannot uninhibit screensaver.")
|
||||
return
|
||||
|
||||
|
||||
try:
|
||||
self.logger.log(f"Attempting to uninhibit screensaver with cookie {self.inhibit_cookie}")
|
||||
self.logger.log(
|
||||
f"Attempting to uninhibit screensaver with cookie {self.inhibit_cookie}")
|
||||
command = [
|
||||
"gdbus", "call", "--session", "--dest", "org.freedesktop.ScreenSaver",
|
||||
"--object-path", "/org/freedesktop/ScreenSaver",
|
||||
@@ -85,13 +97,20 @@ class BackupManager:
|
||||
def start_backup(self, queue, source_path: str, dest_path: str, is_system: bool, is_dry_run: bool = False, exclude_files: Optional[List[Path]] = None, source_size: int = 0, is_compressed: bool = False, is_encrypted: bool = False, mode: str = "incremental", use_trash_bin: bool = False, no_trash_bin: bool = False):
|
||||
self.is_system_process = is_system
|
||||
self._inhibit_screensaver()
|
||||
|
||||
|
||||
mount_point = None
|
||||
if is_encrypted:
|
||||
mount_point = self.encryption_manager.mount(os.path.dirname(dest_path), queue)
|
||||
# Calculate size in GB, add a 2GB buffer
|
||||
size_in_gb = (source_size // (1024**3)) + 2
|
||||
self.logger.log(
|
||||
f"Calculated container size: {size_in_gb} GB for source size {source_size} bytes.")
|
||||
mount_point = self.encryption_manager.mount(
|
||||
os.path.dirname(dest_path), queue, size_gb=size_in_gb)
|
||||
if not mount_point:
|
||||
self.logger.log("Failed to mount encrypted destination. Aborting backup.")
|
||||
queue.put(('completion', {'status': 'error', 'returncode': -1}))
|
||||
self.logger.log(
|
||||
"Failed to mount encrypted destination. Aborting backup.")
|
||||
queue.put(
|
||||
('completion', {'status': 'error', 'returncode': -1}))
|
||||
return None
|
||||
|
||||
thread = threading.Thread(target=self._run_backup_path, args=(
|
||||
@@ -108,27 +127,60 @@ class BackupManager:
|
||||
user_source_name = None
|
||||
if not is_system:
|
||||
# Extract source name from backup_name (e.g., 2025-09-06_10-00-00_user_MyDocs_full.txt -> MyDocs)
|
||||
match = re.match(r"^\d{2}-\d{2}-\d{4}_\d{2}:\d{2}:\d{2}_user_(.+?)_(full|incremental)(_encrypted)?$", backup_name)
|
||||
match = re.match(
|
||||
r"^\d{2}-\d{2}-\d{4}_\d{2}:\d{2}:\d{2}_user_(.+?)_(full|incremental)(_encrypted)?$", backup_name)
|
||||
if match:
|
||||
user_source_name = match.group(1)
|
||||
else:
|
||||
self.logger.log(f"Could not parse user source name from backup_name: {backup_name}")
|
||||
self.logger.log(
|
||||
f"Could not parse user source name from backup_name: {backup_name}")
|
||||
|
||||
# ... (rsync_base_dest and rsync_dest calculation) ...
|
||||
if is_encrypted:
|
||||
if not mount_point:
|
||||
self.logger.log(
|
||||
"Critical error: Encrypted backup run without a mount point.")
|
||||
queue.put(
|
||||
('completion', {'status': 'error', 'returncode': -1}))
|
||||
return
|
||||
if not is_system:
|
||||
rsync_base_dest = os.path.join(mount_point, "user_encrypt")
|
||||
else:
|
||||
rsync_base_dest = mount_point
|
||||
else:
|
||||
if not is_system:
|
||||
rsync_base_dest = os.path.join(
|
||||
pybackup_dir, "user_backups")
|
||||
else:
|
||||
rsync_base_dest = pybackup_dir
|
||||
|
||||
latest_backup_path = self._find_latest_backup(rsync_base_dest, user_source_name)
|
||||
rsync_dest = os.path.join(rsync_base_dest, backup_name)
|
||||
|
||||
# Ensure the base destination for rsync exists, especially for the first run
|
||||
if not os.path.exists(rsync_base_dest):
|
||||
if is_system or is_encrypted:
|
||||
self.logger.log(
|
||||
f"Creating privileged base destination: {rsync_base_dest}")
|
||||
self.encryption_manager._execute_as_root(
|
||||
f"mkdir -p {rsync_base_dest}")
|
||||
else:
|
||||
self.logger.log(
|
||||
f"Creating base destination: {rsync_base_dest}")
|
||||
os.makedirs(rsync_base_dest, exist_ok=True)
|
||||
|
||||
latest_backup_path = self._find_latest_backup(
|
||||
rsync_base_dest, user_source_name)
|
||||
|
||||
# Determine actual mode for user backups
|
||||
if not is_system and not latest_backup_path:
|
||||
mode = "full" # If no previous backup, force full
|
||||
mode = "full" # If no previous backup, force full
|
||||
elif not is_system and latest_backup_path:
|
||||
mode = "incremental" # If previous backup exists, default to incremental
|
||||
mode = "incremental" # If previous backup exists, default to incremental
|
||||
|
||||
command = []
|
||||
if is_system or is_encrypted:
|
||||
command.extend(['pkexec', 'rsync', '-aAXHv'])
|
||||
command.extend(['pkexec', 'rsync', '-aAXHvL'])
|
||||
else:
|
||||
command.extend(['rsync', '-av'])
|
||||
command.extend(['rsync', '-avL'])
|
||||
|
||||
if mode == "incremental" and latest_backup_path and not is_dry_run:
|
||||
command.append(f"--link-dest={latest_backup_path}")
|
||||
@@ -137,7 +189,8 @@ class BackupManager:
|
||||
if exclude_files:
|
||||
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}")
|
||||
command.append(
|
||||
f"--exclude-from={AppConfig.MANUAL_EXCLUDE_LIST_PATH}")
|
||||
if is_dry_run:
|
||||
command.append('--dry-run')
|
||||
|
||||
@@ -145,38 +198,140 @@ class BackupManager:
|
||||
if not is_system:
|
||||
trash_bin_path = os.path.join(rsync_base_dest, ".Trash")
|
||||
if use_trash_bin:
|
||||
command.extend(['--backup', f'--backup-dir={trash_bin_path}', '--delete'])
|
||||
command.extend(
|
||||
['--backup', f'--backup-dir={trash_bin_path}', '--delete'])
|
||||
# Exclude the trash bin itself from the backup
|
||||
command.append(f"--exclude={os.path.basename(trash_bin_path)}/")
|
||||
command.append(
|
||||
f"--exclude={os.path.basename(trash_bin_path)}/")
|
||||
elif no_trash_bin:
|
||||
command.append('--delete')
|
||||
# Exclude the trash bin itself from the backup if it exists from previous use_trash_bin
|
||||
command.append(f"--exclude={os.path.basename(trash_bin_path)}/")
|
||||
command.append(
|
||||
f"--exclude={os.path.basename(trash_bin_path)}/")
|
||||
|
||||
command.extend([source_path, rsync_dest])
|
||||
self.logger.log(f"Rsync command: {' '.join(command)}")
|
||||
|
||||
transferred_size, total_size, stderr = self._execute_rsync(queue, command)
|
||||
return_code = self.process.returncode if self.process else -1
|
||||
max_retries = 1
|
||||
for i in range(max_retries + 1):
|
||||
transferred_size, total_size, stderr = self._execute_rsync(
|
||||
queue, command)
|
||||
return_code = self.process.returncode if self.process else -1
|
||||
|
||||
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.")
|
||||
# Resize logic would need to be adapted for the new mount management
|
||||
# For now, we just log the error
|
||||
queue.put(('error', "Container-Vergrößerung fehlgeschlagen. Backup abgebrochen."))
|
||||
if is_encrypted and "No space left on device" in stderr and i < max_retries:
|
||||
self.logger.log(
|
||||
"Rsync failed due to lack of space. Attempting to dynamically resize container.")
|
||||
try:
|
||||
# Get necessary paths and names
|
||||
container_path = os.path.join(
|
||||
base_dest_path, "pybackup", "user_encrypt", "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}"
|
||||
mapper_path = f"/dev/mapper/{mapper_name}"
|
||||
|
||||
# Calculate new required size
|
||||
current_container_size_bytes = os.path.getsize(
|
||||
container_path)
|
||||
needed_total_bytes = source_size * 1.1 # 10% buffer
|
||||
|
||||
if needed_total_bytes <= current_container_size_bytes:
|
||||
# If container is already big enough but full, just add a fixed amount.
|
||||
gb_to_add = 5
|
||||
new_total_size_bytes = current_container_size_bytes + \
|
||||
(gb_to_add * (1024**3))
|
||||
else:
|
||||
bytes_to_add = needed_total_bytes - current_container_size_bytes
|
||||
gb_to_add = math.ceil(bytes_to_add / (1024**3))
|
||||
new_total_size_bytes = current_container_size_bytes + \
|
||||
(gb_to_add * (1024**3))
|
||||
|
||||
# Check physical drive space
|
||||
physical_drive_stats = shutil.disk_usage(
|
||||
base_dest_path)
|
||||
if physical_drive_stats.free < (gb_to_add * (1024**3)):
|
||||
self.logger.log(
|
||||
f"Not enough physical space on device {base_dest_path} to expand container.")
|
||||
queue.put(
|
||||
('error', "Nicht genügend physischer Speicherplatz für die Vergrößerung."))
|
||||
break
|
||||
|
||||
self.logger.log(
|
||||
f"Calculated expansion size: {gb_to_add} GB")
|
||||
|
||||
resize_script = f'''
|
||||
set -e
|
||||
# Find loop device associated with the container
|
||||
LOOP_DEVICE=$(losetup -j {container_path} | cut -d: -f1 | head -n 1)
|
||||
if [ -z "$LOOP_DEVICE" ]; then
|
||||
echo "Error: Could not find loop device for {container_path}"
|
||||
exit 1
|
||||
fi
|
||||
# 1. Extend backing file (truncate can extend)
|
||||
truncate -s {int(new_total_size_bytes)} {container_path}
|
||||
# 2. Resize loop device
|
||||
losetup -c $LOOP_DEVICE
|
||||
# 3. Resize PV
|
||||
pvresize $LOOP_DEVICE
|
||||
# 4. Extend LV to fill all new space in VG
|
||||
lvextend -l +100%FREE {lv_path}
|
||||
# 5. Resize filesystem
|
||||
resize2fs {mapper_path}
|
||||
'''
|
||||
if self.encryption_manager._execute_as_root(resize_script):
|
||||
self.logger.log(
|
||||
"Container resized successfully. Unmounting and remounting to apply changes.")
|
||||
# Unmount to ensure kernel recognizes the new size
|
||||
self .encryption_manager._unmount_encrypted_backup(
|
||||
base_dest_path)
|
||||
# Remount
|
||||
new_mount_point = self.encryption_manager.mount(
|
||||
base_dest_path, queue)
|
||||
if not new_mount_point:
|
||||
self.logger.log(
|
||||
"Failed to remount after resize. Aborting.")
|
||||
break
|
||||
|
||||
mount_point = new_mount_point
|
||||
self.logger.log(
|
||||
"Remount successful. Retrying rsync...")
|
||||
queue.put(
|
||||
('status_update', f"Container um {gb_to_add} GB vergrößert. Setze Backup fort..."))
|
||||
continue
|
||||
else:
|
||||
self.logger.log("Failed to resize container.")
|
||||
queue.put(
|
||||
('error', "Container-Vergrößerung fehlgeschlagen. Backup abgebrochen."))
|
||||
break
|
||||
except Exception as e:
|
||||
self.logger.log(
|
||||
f"Error during dynamic resize calculation: {e}")
|
||||
break
|
||||
else:
|
||||
# Break the loop if rsync was successful or retries are exhausted
|
||||
break
|
||||
|
||||
if self.process:
|
||||
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
|
||||
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}))
|
||||
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}))
|
||||
|
||||
finally:
|
||||
self._uninhibit_screensaver()
|
||||
@@ -191,12 +346,14 @@ class BackupManager:
|
||||
mounted_path = os.path.join(pybackup_dir, "encrypted")
|
||||
else:
|
||||
return [], []
|
||||
|
||||
|
||||
return self._list_all_backups_from_path(base_dest_path, mounted_path)
|
||||
|
||||
def _list_all_backups_from_path(self, base_dest_path: str, mounted_path: Optional[str] = None):
|
||||
system_backups = self._list_system_backups_from_path(base_dest_path, mounted_path)
|
||||
user_backups = self._list_user_backups_from_path(base_dest_path, mounted_path)
|
||||
system_backups = self._list_system_backups_from_path(
|
||||
base_dest_path, mounted_path)
|
||||
user_backups = self._list_user_backups_from_path(
|
||||
base_dest_path, mounted_path)
|
||||
return system_backups, user_backups
|
||||
|
||||
def list_system_backups(self, base_dest_path: str, mount_if_needed: bool = True) -> Optional[List[Dict[str, str]]]:
|
||||
@@ -205,11 +362,13 @@ class BackupManager:
|
||||
if is_encrypted:
|
||||
if not self.encryption_manager.is_mounted(base_dest_path):
|
||||
if mount_if_needed:
|
||||
self.logger.log("Mounting needed for listing system backups.")
|
||||
mounted_path = self.encryption_manager.mount(base_dest_path)
|
||||
self.logger.log(
|
||||
"Mounting needed for listing system backups.")
|
||||
mounted_path = self.encryption_manager.mount(
|
||||
base_dest_path)
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
if self.encryption_manager.is_mounted(base_dest_path):
|
||||
pybackup_dir = os.path.join(base_dest_path, "pybackup")
|
||||
mounted_path = os.path.join(pybackup_dir, "encrypted")
|
||||
@@ -239,8 +398,10 @@ class BackupManager:
|
||||
else:
|
||||
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 = ""
|
||||
info_file_path = os.path.join(pybackup_dir, item)
|
||||
@@ -249,11 +410,13 @@ class BackupManager:
|
||||
with open(info_file_path, 'r') as f:
|
||||
for line in f:
|
||||
if line.strip().lower().startswith("originalgröße:"):
|
||||
backup_size = line.split(":", 1)[1].strip().split('(')[0].strip()
|
||||
backup_size = line.split(
|
||||
":", 1)[1].strip().split('(')[0].strip()
|
||||
elif line.strip().lower().startswith("kommentar:"):
|
||||
comment = line.split(":", 1)[1].strip()
|
||||
except Exception as e:
|
||||
self.logger.log(f"Could not read info file {info_file_path}: {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,
|
||||
@@ -266,14 +429,19 @@ class BackupManager:
|
||||
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:
|
||||
if not current_group: current_group.append(backup)
|
||||
else: current_group.append(backup)
|
||||
if current_group: grouped_backups.append(current_group)
|
||||
if not current_group:
|
||||
current_group.append(backup)
|
||||
else:
|
||||
current_group.append(backup)
|
||||
if current_group:
|
||||
grouped_backups.append(current_group)
|
||||
grouped_backups.sort(key=lambda g: g[0]['datetime'], reverse=True)
|
||||
final_sorted_list = [item for group in grouped_backups for item in group]
|
||||
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, mount_if_needed: bool = True) -> Optional[List[Dict[str, str]]]:
|
||||
@@ -282,8 +450,10 @@ class BackupManager:
|
||||
if is_encrypted:
|
||||
if not self.encryption_manager.is_mounted(base_dest_path):
|
||||
if mount_if_needed:
|
||||
self.logger.log("Mounting needed for listing user backups.")
|
||||
mounted_path = self.encryption_manager.mount(base_dest_path)
|
||||
self.logger.log(
|
||||
"Mounting needed for listing user backups.")
|
||||
mounted_path = self.encryption_manager.mount(
|
||||
base_dest_path)
|
||||
else:
|
||||
return None
|
||||
if self.encryption_manager.is_mounted(base_dest_path):
|
||||
@@ -307,19 +477,21 @@ class BackupManager:
|
||||
continue
|
||||
date_str, time_str, source_name, backup_type_base, enc_suffix = match.groups()
|
||||
is_encrypted = (enc_suffix is not None)
|
||||
is_compressed = False # User backups are not compressed in this context
|
||||
is_compressed = False # User backups are not compressed in this context
|
||||
backup_name = item.replace(".txt", "").replace("_encrypted", "")
|
||||
|
||||
|
||||
if mounted_path:
|
||||
user_backup_dir = os.path.join(mounted_path, "user_encrypt")
|
||||
full_path = os.path.join(user_backup_dir, backup_name)
|
||||
else:
|
||||
user_backups_dir = os.path.join(pybackup_dir, "user_backups")
|
||||
full_path = os.path.join(user_backups_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 = ""
|
||||
@@ -329,11 +501,13 @@ class BackupManager:
|
||||
with open(info_file_path, 'r') as f:
|
||||
for line in f:
|
||||
if line.strip().lower().startswith("originalgröße:"):
|
||||
backup_size = line.split(":", 1)[1].strip().split('(')[0].strip()
|
||||
backup_size = line.split(
|
||||
":", 1)[1].strip().split('(')[0].strip()
|
||||
elif line.strip().lower().startswith("kommentar:"):
|
||||
comment = line.split(":", 1)[1].strip()
|
||||
except Exception as e:
|
||||
self.logger.log(f"Could not read info file {info_file_path}: {e}")
|
||||
self.logger.log(
|
||||
f"Could not read info file {info_file_path}: {e}")
|
||||
user_backups.append({
|
||||
"date": date_str, "time": time_str, "type": backup_type,
|
||||
"size": backup_size, "folder_name": backup_name, "full_path": full_path,
|
||||
@@ -349,8 +523,9 @@ class BackupManager:
|
||||
|
||||
def _find_latest_backup(self, base_backup_path: str, source_name: Optional[str] = None) -> Optional[str]:
|
||||
"""Finds the most recent backup directory in a given path, optionally filtered by source name."""
|
||||
self.logger.log(f"Searching for latest backup in: {base_backup_path} for source: {source_name or 'All'}")
|
||||
|
||||
self.logger.log(
|
||||
f"Searching for latest backup in: {base_backup_path} for source: {source_name or 'All'}")
|
||||
|
||||
backup_names = []
|
||||
if os.path.isdir(base_backup_path):
|
||||
for item in os.listdir(base_backup_path):
|
||||
@@ -362,7 +537,8 @@ class BackupManager:
|
||||
backup_names.append(item)
|
||||
else:
|
||||
# For system backups or if no source_name is provided, include all
|
||||
if "_system_" in item or "_user_" not in item: # Simple check to exclude other user backups if source_name is None
|
||||
# Simple check to exclude other user backups if source_name is None
|
||||
if "_system_" in item or "_user_" not in item:
|
||||
backup_names.append(item)
|
||||
|
||||
# Sort by date and time (assuming format YYYY-MM-DD_HH-MM-SS or similar at the beginning)
|
||||
@@ -372,22 +548,24 @@ class BackupManager:
|
||||
if not backup_names:
|
||||
self.logger.log("No previous backups found to link against.")
|
||||
return None
|
||||
|
||||
|
||||
latest_backup_name = backup_names[0]
|
||||
latest_backup_path = os.path.join(base_backup_path, latest_backup_name)
|
||||
|
||||
|
||||
if os.path.isdir(latest_backup_path):
|
||||
self.logger.log(f"Found latest backup for --link-dest: {latest_backup_path}")
|
||||
self.logger.log(
|
||||
f"Found latest backup for --link-dest: {latest_backup_path}")
|
||||
return latest_backup_path
|
||||
|
||||
self.logger.log(f"Latest backup entry '{latest_backup_name}' was not a directory. No link will be used.")
|
||||
|
||||
self.logger.log(
|
||||
f"Latest backup entry '{latest_backup_name}' was not a directory. No link will be used.")
|
||||
return None
|
||||
|
||||
def _create_info_file(self, pybackup_dir: str, backup_name: str, source_size: int, is_encrypted: bool):
|
||||
try:
|
||||
info_filename = f"{backup_name}_encrypted.txt" if is_encrypted else f"{backup_name}.txt"
|
||||
info_file_path = os.path.join(pybackup_dir, info_filename)
|
||||
|
||||
|
||||
original_bytes = source_size
|
||||
if source_size > 0:
|
||||
power = 1024
|
||||
@@ -474,16 +652,18 @@ class BackupManager:
|
||||
|
||||
transferred_size = 0
|
||||
total_size = 0
|
||||
summary_regex = re.compile(r"sent ([\d,. ]+) bytes\s+received ([\d,. ]+) bytes")
|
||||
summary_regex = re.compile(
|
||||
r"sent ([\d,. ]+) bytes\s+received ([\d,. ]+) bytes")
|
||||
total_size_regex = re.compile(r"total size is ([\d,. ]+) speedup")
|
||||
|
||||
|
||||
for line in reversed(output_lines):
|
||||
match = summary_regex.search(line)
|
||||
if match and transferred_size == 0:
|
||||
try:
|
||||
sent_str = match.group(1).replace(',', '').replace('.', '')
|
||||
received_str = match.group(2).replace(',', '').replace('.', '')
|
||||
sent_str = match.group(1).replace(
|
||||
',', '').replace('.', '')
|
||||
received_str = match.group(2).replace(
|
||||
',', '').replace('.', '')
|
||||
bytes_sent = int(sent_str)
|
||||
bytes_received = int(received_str)
|
||||
transferred_size = bytes_sent + bytes_received
|
||||
@@ -492,18 +672,22 @@ class BackupManager:
|
||||
except (ValueError, IndexError) as e:
|
||||
self.logger.log(
|
||||
f"Could not parse sent/received bytes from line: '{line}'. Error: {e}")
|
||||
|
||||
|
||||
total_match = total_size_regex.search(line)
|
||||
if total_match and total_size == 0:
|
||||
try:
|
||||
total_size_str = total_match.group(1).replace(',', '').replace('.', '')
|
||||
total_size_str = total_match.group(
|
||||
1).replace(',', '').replace('.', '')
|
||||
total_size = int(total_size_str)
|
||||
self.logger.log(f"Detected total size from summary: {total_size} bytes")
|
||||
except(ValueError, IndexError) as e:
|
||||
self.logger.log(f"Could not parse total size from line: '{line}'. Error: {e}")
|
||||
self.logger.log(
|
||||
f"Detected total size from summary: {total_size} bytes")
|
||||
except (ValueError, IndexError) as e:
|
||||
self.logger.log(
|
||||
f"Could not parse total size from line: '{line}'. Error: {e}")
|
||||
|
||||
self.logger.log(
|
||||
f"_execute_rsync final parsed values: transferred_size={transferred_size}, total_size={total_size}")
|
||||
|
||||
self.logger.log(f"_execute_rsync final parsed values: transferred_size={transferred_size}, total_size={total_size}")
|
||||
|
||||
if transferred_size == 0:
|
||||
bytes_sent = 0
|
||||
bytes_received = 0
|
||||
@@ -511,16 +695,20 @@ class BackupManager:
|
||||
if line.strip().startswith('Total bytes sent:'):
|
||||
try:
|
||||
size_str = line.split(':')[1].strip()
|
||||
bytes_sent = int(size_str.replace(',', '').replace('.', ''))
|
||||
bytes_sent = int(size_str.replace(
|
||||
',', '').replace('.', ''))
|
||||
except (ValueError, IndexError):
|
||||
self.logger.log(f"Could not parse bytes sent from line: {line}")
|
||||
self.logger.log(
|
||||
f"Could not parse bytes sent from line: {line}")
|
||||
elif line.strip().startswith('Total bytes received:'):
|
||||
try:
|
||||
size_str = line.split(':')[1].strip()
|
||||
bytes_received = int(size_str.replace(',', '').replace('.', ''))
|
||||
bytes_received = int(
|
||||
size_str.replace(',', '').replace('.', ''))
|
||||
except (ValueError, IndexError):
|
||||
self.logger.log(f"Could not parse bytes received from line: {line}")
|
||||
|
||||
self.logger.log(
|
||||
f"Could not parse bytes received from line: {line}")
|
||||
|
||||
if bytes_sent > 0 or bytes_received > 0:
|
||||
transferred_size = bytes_sent + bytes_received
|
||||
self.logger.log(
|
||||
@@ -536,7 +724,7 @@ class BackupManager:
|
||||
except Exception as e:
|
||||
self.logger.log(f"An unexpected error occurred: {e}")
|
||||
queue.put(('error', None))
|
||||
|
||||
|
||||
return transferred_size, total_size, stderr_output
|
||||
|
||||
def start_restore(self, source_path: str, dest_path: str, is_compressed: bool):
|
||||
@@ -544,7 +732,8 @@ class BackupManager:
|
||||
try:
|
||||
queue = self.app.queue
|
||||
except AttributeError:
|
||||
self.logger.log("Could not get queue from app instance. Restore progress will not be reported.")
|
||||
self.logger.log(
|
||||
"Could not get queue from app instance. Restore progress will not be reported.")
|
||||
from queue import Queue
|
||||
queue = Queue()
|
||||
|
||||
@@ -572,10 +761,12 @@ class BackupManager:
|
||||
status = 'error'
|
||||
|
||||
except Exception as e:
|
||||
self.logger.log(f"An unexpected error occurred during restore: {e}")
|
||||
self.logger.log(
|
||||
f"An unexpected error occurred during restore: {e}")
|
||||
status = 'error'
|
||||
finally:
|
||||
queue.put(('completion', {'status': status, 'returncode': 0 if status == 'success' else 1}))
|
||||
queue.put(
|
||||
('completion', {'status': status, 'returncode': 0 if status == 'success' else 1}))
|
||||
|
||||
def get_scheduled_jobs(self) -> List[Dict[str, Any]]:
|
||||
jobs_list = []
|
||||
@@ -650,7 +841,8 @@ class BackupManager:
|
||||
if line.strip().lower().startswith("kommentar:"):
|
||||
return line.split(":", 1)[1].strip()
|
||||
except Exception as e:
|
||||
self.logger.log(f"Error reading comment from {info_file_path}: {e}")
|
||||
self.logger.log(
|
||||
f"Error reading comment from {info_file_path}: {e}")
|
||||
return ""
|
||||
|
||||
def update_comment(self, info_file_path: str, new_comment: str):
|
||||
@@ -670,13 +862,14 @@ class BackupManager:
|
||||
comment_found = True
|
||||
else:
|
||||
new_lines.append(line)
|
||||
|
||||
|
||||
if not comment_found and new_comment:
|
||||
new_lines.append(f"Kommentar: {new_comment}\n")
|
||||
|
||||
with open(info_file_path, 'w') as f:
|
||||
f.writelines(new_lines)
|
||||
self.logger.log(f"Successfully updated comment in {info_file_path}")
|
||||
self.logger.log(
|
||||
f"Successfully updated comment in {info_file_path}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.log(f"Error updating comment in {info_file_path}: {e}")
|
||||
@@ -755,56 +948,67 @@ class BackupManager:
|
||||
"""Runs the deletion and puts a message on the queue when done."""
|
||||
try:
|
||||
if is_encrypted:
|
||||
self.logger.log(f"Starting encrypted deletion for {path_to_delete}")
|
||||
self.logger.log(
|
||||
f"Starting encrypted deletion for {path_to_delete}")
|
||||
if not password:
|
||||
self.logger.log("Password not provided for encrypted deletion.")
|
||||
self.logger.log(
|
||||
"Password not provided for encrypted deletion.")
|
||||
queue.put(('deletion_complete', False))
|
||||
return
|
||||
|
||||
mount_point = self.encryption_manager.mount(base_dest_path, queue)
|
||||
mount_point = self.encryption_manager.mount(
|
||||
base_dest_path, queue)
|
||||
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}")
|
||||
|
||||
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}'
|
||||
"""
|
||||
success = self.encryption_manager._execute_as_root(script_content)
|
||||
success = self.encryption_manager._execute_as_root(
|
||||
script_content)
|
||||
|
||||
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.")
|
||||
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}")
|
||||
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}")
|
||||
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}")
|
||||
|
||||
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}")
|
||||
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}")
|
||||
self.logger.log(
|
||||
f"Failed to delete unencrypted user backup {path_to_delete}: {e}")
|
||||
queue.put(('deletion_complete', False))
|
||||
|
||||
except Exception as e:
|
||||
@@ -835,7 +1039,8 @@ set -e
|
||||
if is_system or is_encrypted:
|
||||
self.logger.log("Executing compression and cleanup as root.")
|
||||
if self.encryption_manager._execute_as_root(script_content):
|
||||
self.logger.log("Compression and cleanup script executed successfully.")
|
||||
self.logger.log(
|
||||
"Compression and cleanup script executed successfully.")
|
||||
return True
|
||||
else:
|
||||
self.logger.log("Compression and cleanup script failed.")
|
||||
@@ -843,20 +1048,25 @@ set -e
|
||||
else:
|
||||
try:
|
||||
self.logger.log(f"Executing local command: {tar_command}")
|
||||
tar_result = subprocess.run(tar_command, shell=True, capture_output=True, text=True, check=True)
|
||||
self.logger.log(f"tar command successful. Output: {tar_result.stdout}")
|
||||
tar_result = subprocess.run(
|
||||
tar_command, shell=True, capture_output=True, text=True, check=True)
|
||||
self.logger.log(
|
||||
f"tar command successful. Output: {tar_result.stdout}")
|
||||
|
||||
self.logger.log(f"Executing local command: {rm_command}")
|
||||
rm_result = subprocess.run(rm_command, shell=True, capture_output=True, text=True, check=True)
|
||||
self.logger.log(f"rm command successful. Output: {rm_result.stdout}")
|
||||
|
||||
rm_result = subprocess.run(
|
||||
rm_command, shell=True, capture_output=True, text=True, check=True)
|
||||
self.logger.log(
|
||||
f"rm command successful. Output: {rm_result.stdout}")
|
||||
|
||||
return True
|
||||
except subprocess.CalledProcessError as e:
|
||||
self.logger.log(f"A command failed during local compression/cleanup. Return code: {e.returncode}")
|
||||
self.logger.log(
|
||||
f"A command failed during local compression/cleanup. Return code: {e.returncode}")
|
||||
self.logger.log(f"Stdout: {e.stdout}")
|
||||
self.logger.log(f"Stderr: {e.stderr}")
|
||||
return False
|
||||
except Exception as e:
|
||||
self.logger.log(f"An unexpected error occurred during local compression/cleanup: {e}")
|
||||
self.logger.log(
|
||||
f"An unexpected error occurred during local compression/cleanup: {e}")
|
||||
return False
|
||||
|
||||
|
@@ -25,6 +25,7 @@ class EncryptionManager:
|
||||
self.session_password = None
|
||||
self.mounted_destinations = set()
|
||||
self.auth_method = None
|
||||
self.is_mounting = False
|
||||
|
||||
def get_key_file_path(self, base_dest_path: str) -> str:
|
||||
"""Generates the standard path for the key file for a given destination."""
|
||||
@@ -94,60 +95,68 @@ class EncryptionManager:
|
||||
mount_point = os.path.join(pybackup_dir, "encrypted")
|
||||
return os.path.ismount(mount_point)
|
||||
|
||||
def mount(self, base_dest_path: str, queue=None) -> Optional[str]:
|
||||
if not self.is_encrypted(base_dest_path):
|
||||
self.auth_method = None
|
||||
return None
|
||||
|
||||
if self.is_mounted(base_dest_path):
|
||||
self.mounted_destinations.add(base_dest_path)
|
||||
pybackup_dir = os.path.join(base_dest_path, "pybackup")
|
||||
return os.path.join(pybackup_dir, "encrypted")
|
||||
|
||||
username = os.path.basename(base_dest_path.rstrip('/'))
|
||||
|
||||
# Use a dummy queue if none is provided
|
||||
if queue is None:
|
||||
from queue import Queue
|
||||
queue = Queue()
|
||||
|
||||
# 1. Try keyring
|
||||
password = self.get_password_from_keyring(username)
|
||||
if password:
|
||||
self.logger.log("Found password in keyring. Attempting to mount.")
|
||||
mount_point = self._setup_encrypted_backup(queue, base_dest_path, 0, password=password)
|
||||
if mount_point:
|
||||
self.auth_method = "keyring"
|
||||
self.mounted_destinations.add(base_dest_path)
|
||||
return mount_point
|
||||
|
||||
# 2. Try key file
|
||||
key_file_path = self.get_key_file_path(base_dest_path)
|
||||
if os.path.exists(key_file_path):
|
||||
self.logger.log(f"Found key file at {key_file_path}. Attempting to mount.")
|
||||
mount_point = self._setup_encrypted_backup(queue, base_dest_path, 0, key_file=key_file_path)
|
||||
if mount_point:
|
||||
self.auth_method = "keyfile"
|
||||
self.mounted_destinations.add(base_dest_path)
|
||||
return mount_point
|
||||
|
||||
# 3. Prompt for password
|
||||
self.logger.log("No password in keyring or key file found. Prompting user.")
|
||||
password = self.get_password(username, confirm=False)
|
||||
if not password:
|
||||
self.logger.log("No password provided, cannot mount container.")
|
||||
self.auth_method = None
|
||||
def mount(self, base_dest_path: str, queue=None, size_gb: int = 0) -> Optional[str]:
|
||||
if self.is_mounting:
|
||||
self.logger.log("Mount process already in progress. Aborting new request.")
|
||||
return None
|
||||
|
||||
mount_point = self._setup_encrypted_backup(queue, base_dest_path, 0, password=password)
|
||||
if mount_point:
|
||||
self.auth_method = "password"
|
||||
self.mounted_destinations.add(base_dest_path)
|
||||
return mount_point
|
||||
self.is_mounting = True
|
||||
try:
|
||||
if self.is_mounted(base_dest_path):
|
||||
self.mounted_destinations.add(base_dest_path)
|
||||
pybackup_dir = os.path.join(base_dest_path, "pybackup")
|
||||
return os.path.join(pybackup_dir, "encrypted")
|
||||
|
||||
username = os.path.basename(base_dest_path.rstrip('/'))
|
||||
|
||||
# Use a dummy queue if none is provided
|
||||
if queue is None:
|
||||
from queue import Queue
|
||||
queue = Queue()
|
||||
|
||||
# 1. Try keyring
|
||||
password = self.get_password_from_keyring(username)
|
||||
if password:
|
||||
self.logger.log("Found password in keyring. Attempting to mount.")
|
||||
mount_point = self._setup_encrypted_backup(queue, base_dest_path, size_gb, password=password)
|
||||
if mount_point:
|
||||
self.auth_method = "keyring"
|
||||
self.mounted_destinations.add(base_dest_path)
|
||||
return mount_point
|
||||
else:
|
||||
# If mounting with keyring key fails, stop here and report error.
|
||||
self.logger.log("Mounting with keyring password failed. Aborting mount attempt.")
|
||||
return None
|
||||
|
||||
# 2. Try key file
|
||||
key_file_path = self.get_key_file_path(base_dest_path)
|
||||
if os.path.exists(key_file_path):
|
||||
self.logger.log(f"Found key file at {key_file_path}. Attempting to mount.")
|
||||
mount_point = self._setup_encrypted_backup(queue, base_dest_path, size_gb, key_file=key_file_path)
|
||||
if mount_point:
|
||||
self.auth_method = "keyfile"
|
||||
self.mounted_destinations.add(base_dest_path)
|
||||
return mount_point
|
||||
|
||||
# 3. Prompt for password
|
||||
self.logger.log("No password in keyring or key file found. Prompting user.")
|
||||
password = self.get_password(username, confirm=False)
|
||||
if not password:
|
||||
self.logger.log("No password provided, cannot mount container.")
|
||||
self.auth_method = None
|
||||
return None
|
||||
|
||||
mount_point = self._setup_encrypted_backup(queue, base_dest_path, size_gb, password=password)
|
||||
if mount_point:
|
||||
self.auth_method = "password"
|
||||
self.mounted_destinations.add(base_dest_path)
|
||||
return mount_point
|
||||
finally:
|
||||
self.is_mounting = False
|
||||
|
||||
def unmount(self, base_dest_path: str):
|
||||
if base_dest_path in self.mounted_destinations:
|
||||
self._cleanup_encrypted_backup(base_dest_path)
|
||||
self._unmount_encrypted_backup(base_dest_path)
|
||||
self.mounted_destinations.remove(base_dest_path)
|
||||
|
||||
def unmount_all(self):
|
||||
@@ -156,6 +165,35 @@ class EncryptionManager:
|
||||
for path in list(self.mounted_destinations):
|
||||
self.unmount(path)
|
||||
|
||||
def _unmount_encrypted_backup(self, base_dest_path: str):
|
||||
""" Gently unmounts the container without destroying LVM structures. """
|
||||
pybackup_dir = os.path.join(base_dest_path, "pybackup")
|
||||
user_encrypt_dir = os.path.join(pybackup_dir, "user_encrypt")
|
||||
mount_point = os.path.join(pybackup_dir, "encrypted")
|
||||
container_path = os.path.join(user_encrypt_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"Unmounting encrypted LVM backup for {base_dest_path}")
|
||||
find_loop_device_cmd = f"losetup -j {container_path} | cut -d: -f1"
|
||||
script = f"""
|
||||
LOOP_DEVICE=$({find_loop_device_cmd} | head -n 1)
|
||||
if mountpoint -q {mount_point}; then
|
||||
umount {mount_point} || echo "Umount failed, continuing..."
|
||||
fi
|
||||
if [ -e /dev/mapper/{mapper_name} ]; then
|
||||
cryptsetup luksClose {mapper_name} || echo "luksClose failed, continuing..."
|
||||
fi
|
||||
if vgdisplay {vg_name} >/dev/null 2>&1; then
|
||||
vgchange -an {vg_name} || echo "Could not deactivate volume group {vg_name}."
|
||||
fi
|
||||
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 LVM backup unmount script failed.")
|
||||
|
||||
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 LVM-based encrypted container at {base_dest_path}")
|
||||
|
||||
@@ -199,7 +237,7 @@ class EncryptionManager:
|
||||
"""
|
||||
if not self._execute_as_root(script):
|
||||
self.logger.log("Failed to unlock existing LVM container.")
|
||||
self._cleanup_encrypted_backup(base_dest_path)
|
||||
self._destroy_encrypted_structures(base_dest_path)
|
||||
return None
|
||||
self.logger.log(f"Encrypted LVM container unlocked and mounted at {mount_point}")
|
||||
return mount_point
|
||||
@@ -222,7 +260,7 @@ class EncryptionManager:
|
||||
"""
|
||||
if not self._execute_as_root(script):
|
||||
self.logger.log("Failed to create and setup LVM-based encrypted container.")
|
||||
self._cleanup_encrypted_backup(base_dest_path)
|
||||
self._destroy_encrypted_structures(base_dest_path)
|
||||
if os.path.exists(container_path):
|
||||
self._execute_as_root(f"rm -f {container_path}")
|
||||
queue.put(('error', "Failed to setup LVM-based encrypted container."))
|
||||
@@ -230,7 +268,7 @@ class EncryptionManager:
|
||||
self.logger.log(f"Encrypted LVM container is ready and mounted at {mount_point}")
|
||||
return mount_point
|
||||
|
||||
def _cleanup_encrypted_backup(self, base_dest_path: str):
|
||||
def _destroy_encrypted_structures(self, base_dest_path: str):
|
||||
pybackup_dir = os.path.join(base_dest_path, "pybackup")
|
||||
user_encrypt_dir = os.path.join(pybackup_dir, "user_encrypt")
|
||||
mount_point = os.path.join(pybackup_dir, "encrypted")
|
||||
@@ -240,15 +278,28 @@ class EncryptionManager:
|
||||
mapper_name = f"pybackup_luks_{base_name}"
|
||||
self.logger.log(f"Cleaning up encrypted LVM backup for {base_dest_path}")
|
||||
find_loop_device_cmd = f"losetup -j {container_path} | cut -d: -f1"
|
||||
lv_name = "backup_lv"
|
||||
lv_path = f"/dev/{vg_name}/{lv_name}"
|
||||
|
||||
script = f"""
|
||||
set -x # Log executed commands
|
||||
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."
|
||||
if mountpoint -q {mount_point}; then
|
||||
umount {mount_point} || echo "Umount failed, continuing cleanup..."
|
||||
fi
|
||||
if [ -e /dev/mapper/{mapper_name} ]; then
|
||||
cryptsetup luksClose {mapper_name} || echo "luksClose failed, continuing cleanup..."
|
||||
fi
|
||||
# Deactivate and remove all LVM structures associated with the VG
|
||||
if vgdisplay {vg_name} >/dev/null 2>&1; then
|
||||
vgchange -an {vg_name} || echo "Could not deactivate volume group {vg_name}."
|
||||
lvchange -an {lv_path} >/dev/null 2>&1 || echo "lvchange failed, continuing..."
|
||||
vgchange -an {vg_name} || echo "vgchange -an failed, continuing cleanup..."
|
||||
lvremove -f {vg_name} || echo "lvremove failed, continuing cleanup..."
|
||||
vgremove -f {vg_name} || echo "vgremove failed, continuing cleanup..."
|
||||
fi
|
||||
if [ -n "$LOOP_DEVICE" ] && losetup $LOOP_DEVICE >/dev/null 2>&1; then
|
||||
losetup -d $LOOP_DEVICE || echo "Could not detach loop device $LOOP_DEVICE."
|
||||
pvremove -f $LOOP_DEVICE || echo "pvremove failed, continuing cleanup..."
|
||||
losetup -d $LOOP_DEVICE || echo "losetup -d failed, continuing cleanup..."
|
||||
fi
|
||||
"""
|
||||
if not self._execute_as_root(script):
|
||||
|
47
main_app.py
47
main_app.py
@@ -147,7 +147,8 @@ class MainApplication(tk.Tk):
|
||||
self.sidebar_buttons_frame, text=Msg.STR["settings"], command=lambda: self.navigation.toggle_settings_frame(4), style="Sidebar.TButton")
|
||||
self.settings_button.pack(fill=tk.X, pady=10)
|
||||
|
||||
self.header_frame = HeaderFrame(self.content_frame, self.image_manager, self.backup_manager.encryption_manager, self)
|
||||
self.header_frame = HeaderFrame(
|
||||
self.content_frame, self.image_manager, self.backup_manager.encryption_manager, self)
|
||||
self.header_frame.grid(row=0, column=0, sticky="nsew")
|
||||
|
||||
self.top_bar = ttk.Frame(self.content_frame)
|
||||
@@ -221,7 +222,7 @@ class MainApplication(tk.Tk):
|
||||
self._setup_scheduler_frame()
|
||||
self._setup_settings_frame()
|
||||
self._setup_backup_content_frame()
|
||||
|
||||
|
||||
self._setup_task_bar()
|
||||
|
||||
self.source_size_frame = ttk.LabelFrame(
|
||||
@@ -301,7 +302,7 @@ class MainApplication(tk.Tk):
|
||||
self.protocol("WM_DELETE_WINDOW", self.on_closing)
|
||||
|
||||
def _load_state_and_initialize(self):
|
||||
self.log_window.clear_log()
|
||||
# self.log_window.clear_log()
|
||||
last_mode = self.config_manager.get_setting("last_mode", "backup")
|
||||
|
||||
backup_source_path = self.config_manager.get_setting(
|
||||
@@ -338,13 +339,17 @@ class MainApplication(tk.Tk):
|
||||
if hasattr(self, 'header_frame'):
|
||||
self.header_frame.refresh_status()
|
||||
|
||||
container_path = os.path.join(backup_dest_path, "pybackup_encrypted.luks")
|
||||
container_path = os.path.join(
|
||||
backup_dest_path, "pybackup_encrypted.luks")
|
||||
if os.path.exists(container_path):
|
||||
username = os.path.basename(backup_dest_path.rstrip('/'))
|
||||
password = self.backup_manager.encryption_manager.get_password_from_keyring(username)
|
||||
password = self.backup_manager.encryption_manager.get_password_from_keyring(
|
||||
username)
|
||||
if password:
|
||||
self.backup_manager.encryption_manager.unlock_container(backup_dest_path, password)
|
||||
app_logger.log("Automatically unlocked encrypted container.")
|
||||
self.backup_manager.encryption_manager.unlock_container(
|
||||
backup_dest_path, password)
|
||||
app_logger.log(
|
||||
"Automatically unlocked encrypted container.")
|
||||
if hasattr(self, 'header_frame'):
|
||||
self.header_frame.refresh_status()
|
||||
|
||||
@@ -384,7 +389,7 @@ class MainApplication(tk.Tk):
|
||||
self.after(100, self.actions.on_sidebar_button_click,
|
||||
restore_dest_folder)
|
||||
self._process_queue()
|
||||
self._update_sync_mode_display() # Call after loading state
|
||||
self._update_sync_mode_display() # Call after loading state
|
||||
|
||||
def _setup_log_window(self):
|
||||
self.log_frame = ttk.Frame(self.content_frame)
|
||||
@@ -560,7 +565,7 @@ class MainApplication(tk.Tk):
|
||||
try:
|
||||
self.destroy()
|
||||
except tk.TclError:
|
||||
pass # App is already destroyed
|
||||
pass # App is already destroyed
|
||||
|
||||
def _process_queue(self):
|
||||
try:
|
||||
@@ -581,7 +586,8 @@ class MainApplication(tk.Tk):
|
||||
self.accurate_calculation_running = False
|
||||
self.animated_icon.stop("DISABLE")
|
||||
else:
|
||||
current_folder_name = self.left_canvas_data.get('folder')
|
||||
current_folder_name = self.left_canvas_data.get(
|
||||
'folder')
|
||||
if current_folder_name == button_text:
|
||||
if self.left_canvas_animation:
|
||||
self.left_canvas_animation.stop()
|
||||
@@ -622,11 +628,13 @@ class MainApplication(tk.Tk):
|
||||
self.drawing.update_target_projection()
|
||||
self.animated_icon.stop("DISABLE")
|
||||
self.task_progress.stop()
|
||||
self.task_progress.config(mode="determinate", value=0)
|
||||
self.task_progress.config(
|
||||
mode="determinate", value=0)
|
||||
self.actions._set_ui_state(True)
|
||||
self.genaue_berechnung_var.set(False)
|
||||
self.accurate_calculation_running = False
|
||||
self.start_pause_button.config(text=Msg.STR["start"])
|
||||
self.start_pause_button.config(
|
||||
text=Msg.STR["start"])
|
||||
if status == 'success':
|
||||
self.info_label.config(
|
||||
text=Msg.STR["accurate_size_success"], foreground="#0078d7")
|
||||
@@ -753,22 +761,27 @@ class MainApplication(tk.Tk):
|
||||
self.encrypted_cb.config(state="disabled")
|
||||
|
||||
self.actions._refresh_backup_options_ui()
|
||||
self._update_sync_mode_display() # Update sync mode display after options are loaded
|
||||
# Update sync mode display after options are loaded
|
||||
self._update_sync_mode_display()
|
||||
|
||||
def _update_sync_mode_display(self):
|
||||
use_trash_bin = self.config_manager.get_setting("use_trash_bin", False)
|
||||
no_trash_bin = self.config_manager.get_setting("no_trash_bin", False)
|
||||
|
||||
if self.left_canvas_data.get('folder') == "Computer":
|
||||
self.sync_mode_label.config(text="") # Not applicable for system backups
|
||||
# Not applicable for system backups
|
||||
self.sync_mode_label.config(text="")
|
||||
return
|
||||
|
||||
if no_trash_bin:
|
||||
self.sync_mode_label.config(text=Msg.STR["sync_mode_pure_sync"], foreground="red")
|
||||
self.sync_mode_label.config(
|
||||
text=Msg.STR["sync_mode_pure_sync"], foreground="red")
|
||||
elif use_trash_bin:
|
||||
self.sync_mode_label.config(text=Msg.STR["sync_mode_trash_bin"], foreground="orange")
|
||||
self.sync_mode_label.config(
|
||||
text=Msg.STR["sync_mode_trash_bin"], foreground="orange")
|
||||
else:
|
||||
self.sync_mode_label.config(text=Msg.STR["sync_mode_no_delete"], foreground="green")
|
||||
self.sync_mode_label.config(
|
||||
text=Msg.STR["sync_mode_no_delete"], foreground="green")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
@@ -29,7 +29,7 @@ class Actions:
|
||||
def _update_backup_type_controls(self):
|
||||
# Only applies to system backups in backup mode
|
||||
if self.app.mode != 'backup' or self.app.left_canvas_data.get('folder') != "Computer":
|
||||
self._set_backup_type("full") # Default for user backups
|
||||
self._set_backup_type("full") # Default for user backups
|
||||
self.app.full_backup_cb.config(state='disabled')
|
||||
self.app.incremental_cb.config(state='disabled')
|
||||
return
|
||||
@@ -50,11 +50,12 @@ class Actions:
|
||||
return
|
||||
|
||||
is_encrypted_backup = self.app.encrypted_var.get()
|
||||
|
||||
system_backups = self.app.backup_manager.list_system_backups(self.app.destination_path, mount_if_needed=False)
|
||||
|
||||
if system_backups is None: # Encrypted, but not inspected
|
||||
full_backup_exists = True # Assume one exists to be safe
|
||||
|
||||
system_backups = self.app.backup_manager.list_system_backups(
|
||||
self.app.destination_path, mount_if_needed=False)
|
||||
|
||||
if system_backups is None: # Encrypted, but not inspected
|
||||
full_backup_exists = True # Assume one exists to be safe
|
||||
else:
|
||||
for backup in system_backups:
|
||||
# Match the encryption status and check if it's a full backup
|
||||
@@ -79,7 +80,7 @@ class Actions:
|
||||
if self.app.encrypted_var.get():
|
||||
self.app.compressed_var.set(False)
|
||||
self.app.compressed_cb.config(state="disabled")
|
||||
|
||||
|
||||
if self.app.compressed_var.get():
|
||||
self.app.encrypted_var.set(False)
|
||||
self.app.encrypted_cb.config(state="disabled")
|
||||
@@ -157,7 +158,8 @@ class Actions:
|
||||
if AppConfig.USER_EXCLUDE_LIST_PATH.exists():
|
||||
exclude_file_paths.append(AppConfig.USER_EXCLUDE_LIST_PATH)
|
||||
if AppConfig.MANUAL_EXCLUDE_LIST_PATH.exists():
|
||||
exclude_file_paths.append(AppConfig.MANUAL_EXCLUDE_LIST_PATH)
|
||||
exclude_file_paths.append(
|
||||
AppConfig.MANUAL_EXCLUDE_LIST_PATH)
|
||||
|
||||
base_dest = self.app.destination_path
|
||||
correct_parent_dir = os.path.join(base_dest, "pybackup")
|
||||
@@ -195,7 +197,7 @@ class Actions:
|
||||
self.app.navigation.toggle_mode(
|
||||
self.app.mode, trigger_calculation=False)
|
||||
|
||||
self.app.log_window.clear_log()
|
||||
# self.app.log_window.clear_log()
|
||||
|
||||
REVERSE_FOLDER_MAP = {
|
||||
"Computer": "Computer",
|
||||
@@ -245,7 +247,8 @@ class Actions:
|
||||
|
||||
self._start_left_canvas_calculation(
|
||||
button_text, str(folder_path), icon_name, extra_info)
|
||||
self.app._update_sync_mode_display() # Update sync mode display when source changes
|
||||
# Update sync mode display when source changes
|
||||
self.app._update_sync_mode_display()
|
||||
|
||||
def _start_left_canvas_calculation(self, button_text, folder_path, icon_name, extra_info):
|
||||
self.app.start_pause_button.config(state="disabled")
|
||||
@@ -340,7 +343,8 @@ class Actions:
|
||||
if self.app.mode == "backup":
|
||||
# Unmount previous destination if it was mounted
|
||||
if self.app.destination_path:
|
||||
self.app.backup_manager.encryption_manager.unmount(self.app.destination_path)
|
||||
self.app.backup_manager.encryption_manager.unmount(
|
||||
self.app.destination_path)
|
||||
|
||||
self.app.destination_path = path
|
||||
|
||||
@@ -364,13 +368,13 @@ class Actions:
|
||||
size_str = f"{used / (1024**3):.2f} GB / {total / (1024**3):.2f} GB"
|
||||
|
||||
self.app.right_canvas_data.update({
|
||||
'folder': os.path.basename(path.rstrip('/')),
|
||||
'folder': os.path.basename(path.rstrip('/')),
|
||||
'path_display': path,
|
||||
'size': size_str
|
||||
})
|
||||
self.app.config_manager.set_setting(
|
||||
"backup_destination_path", path)
|
||||
self.app.header_frame.refresh_status() # Refresh keyring status
|
||||
self.app.header_frame.refresh_status() # Refresh keyring status
|
||||
self.app.drawing.redraw_right_canvas()
|
||||
self.app.drawing.update_target_projection()
|
||||
|
||||
@@ -381,7 +385,7 @@ class Actions:
|
||||
|
||||
elif self.app.mode == "restore":
|
||||
self.app.right_canvas_data.update({
|
||||
'folder': os.path.basename(path.rstrip('/')),
|
||||
'folder': os.path.basename(path.rstrip('/')),
|
||||
'path_display': path,
|
||||
'size': ''
|
||||
})
|
||||
@@ -599,7 +603,7 @@ class Actions:
|
||||
self.app.start_pause_button["text"] = Msg.STR["cancel_backup"]
|
||||
self.app.update_idletasks()
|
||||
|
||||
self.app.log_window.clear_log()
|
||||
# self.app.log_window.clear_log()
|
||||
self._set_ui_state(False, allow_log_and_backup_toggle=True)
|
||||
|
||||
self.app.animated_icon.start()
|
||||
@@ -607,9 +611,10 @@ class Actions:
|
||||
if self.app.mode == "backup":
|
||||
source_folder = self.app.left_canvas_data.get('folder')
|
||||
source_size_bytes = self.app.source_size_bytes
|
||||
|
||||
|
||||
if not source_folder:
|
||||
app_logger.log("No source folder selected, aborting backup.")
|
||||
app_logger.log(
|
||||
"No source folder selected, aborting backup.")
|
||||
self.app.backup_is_running = False
|
||||
self.app.start_pause_button["text"] = Msg.STR["start"]
|
||||
self._set_ui_state(True)
|
||||
@@ -620,7 +625,7 @@ class Actions:
|
||||
self._start_system_backup(mode, source_size_bytes)
|
||||
else:
|
||||
self._start_user_backup()
|
||||
else: # restore mode
|
||||
else: # restore mode
|
||||
# Restore logic would go here
|
||||
pass
|
||||
|
||||
@@ -641,9 +646,11 @@ class Actions:
|
||||
password = None
|
||||
if is_encrypted:
|
||||
username = os.path.basename(base_dest.rstrip('/'))
|
||||
password = self.app.backup_manager.encryption_manager.get_password(username, confirm=True)
|
||||
password = self.app.backup_manager.encryption_manager.get_password(
|
||||
username, confirm=True)
|
||||
if not password:
|
||||
app_logger.log("Encryption enabled, but no password provided. Aborting backup.")
|
||||
app_logger.log(
|
||||
"Encryption enabled, but no password provided. Aborting backup.")
|
||||
self.app.backup_is_running = False
|
||||
self.app.start_pause_button["text"] = Msg.STR["start"]
|
||||
self._set_ui_state(True)
|
||||
@@ -659,7 +666,8 @@ class Actions:
|
||||
date_str = now.strftime("%d-%m-%Y")
|
||||
time_str = now.strftime("%H:%M:%S")
|
||||
folder_name = f"{date_str}_{time_str}_system_{mode}"
|
||||
final_dest = os.path.join(base_dest, folder_name) # The backup_manager will add /pybackup/
|
||||
# The backup_manager will add /pybackup/
|
||||
final_dest = os.path.join(base_dest, folder_name)
|
||||
self.app.current_backup_path = final_dest
|
||||
|
||||
source_size_bytes = self.app.left_canvas_data.get('total_bytes', 0)
|
||||
@@ -685,8 +693,7 @@ class Actions:
|
||||
source_size=source_size_bytes,
|
||||
is_compressed=is_compressed,
|
||||
is_encrypted=is_encrypted,
|
||||
mode=mode,
|
||||
password=password)
|
||||
mode=mode)
|
||||
|
||||
def _start_user_backup(self):
|
||||
base_dest = self.app.destination_path
|
||||
@@ -706,29 +713,33 @@ class Actions:
|
||||
password = None
|
||||
if is_encrypted:
|
||||
username = os.path.basename(base_dest.rstrip('/'))
|
||||
password = self.app.backup_manager.encryption_manager.get_password(username, confirm=True)
|
||||
password = self.app.backup_manager.encryption_manager.get_password(
|
||||
username, confirm=True)
|
||||
if not password:
|
||||
app_logger.log("Encryption enabled, but no password provided. Aborting backup.")
|
||||
app_logger.log(
|
||||
"Encryption enabled, but no password provided. Aborting backup.")
|
||||
self.app.backup_is_running = False
|
||||
self.app.start_pause_button["text"] = Msg.STR["start"]
|
||||
self._set_ui_state(True)
|
||||
return
|
||||
|
||||
|
||||
# Determine mode for user backup based on UI selection
|
||||
mode = "full" if self.app.vollbackup_var.get() else "incremental"
|
||||
|
||||
now = datetime.datetime.now()
|
||||
date_str = now.strftime("%d-%m-%Y")
|
||||
time_str = now.strftime("%H:%M:%S")
|
||||
folder_name = f"{date_str}_{time_str}_user_{source_name}"
|
||||
|
||||
folder_name = f"{date_str}_{time_str}_user_{source_name}_{mode}"
|
||||
|
||||
final_dest = os.path.join(base_dest, folder_name)
|
||||
self.app.current_backup_path = final_dest
|
||||
|
||||
is_dry_run = self.app.testlauf_var.get()
|
||||
is_compressed = self.app.compressed_var.get()
|
||||
use_trash_bin = self.app.config_manager.get_setting("use_trash_bin", False)
|
||||
no_trash_bin = self.app.config_manager.get_setting("no_trash_bin", False)
|
||||
|
||||
# Determine mode for user backup based on UI selection
|
||||
mode = "full" if self.app.vollbackup_var.get() else "incremental"
|
||||
use_trash_bin = self.app.config_manager.get_setting(
|
||||
"use_trash_bin", False)
|
||||
no_trash_bin = self.app.config_manager.get_setting(
|
||||
"no_trash_bin", False)
|
||||
|
||||
self.app.backup_manager.start_backup(
|
||||
queue=self.app.queue,
|
||||
@@ -736,11 +747,18 @@ class Actions:
|
||||
dest_path=final_dest,
|
||||
is_system=False,
|
||||
is_dry_run=is_dry_run,
|
||||
exclude_files=None,
|
||||
exclude_files=None,
|
||||
source_size=source_size_bytes,
|
||||
is_compressed=is_compressed,
|
||||
is_encrypted=is_encrypted,
|
||||
mode=mode,
|
||||
password=password,
|
||||
use_trash_bin=use_trash_bin,
|
||||
no_trash_bin=no_trash_bin)
|
||||
no_trash_bin=no_trash_bin)
|
||||
|
||||
now = datetime.datetime.now()
|
||||
date_str = now.strftime("%d-%m-%Y")
|
||||
time_str = now.strftime("%H:%M:%S")
|
||||
folder_name = f"{date_str}_{time_str}_user_{source_name}_{mode}"
|
||||
|
||||
final_dest = os.path.join(base_dest, folder_name)
|
||||
self.app.current_backup_path = final_dest
|
||||
|
@@ -148,6 +148,25 @@ class BackupContentFrame(ttk.Frame):
|
||||
|
||||
self.base_backup_path = backup_path
|
||||
|
||||
# Check if the destination is encrypted and trigger mount if necessary
|
||||
is_encrypted = self.backup_manager.encryption_manager.is_encrypted(
|
||||
backup_path)
|
||||
if is_encrypted and not self.backup_manager.encryption_manager.is_mounted(backup_path):
|
||||
app_logger.log(
|
||||
"Encrypted destination is not mounted. Attempting to mount.")
|
||||
mount_point = self.backup_manager.encryption_manager.mount(
|
||||
backup_path)
|
||||
if not mount_point:
|
||||
app_logger.log("Mount failed. Cannot display backup content.")
|
||||
MessageDialog(
|
||||
message_type="error", title=Msg.STR["error"], text=Msg.STR["err_unlock_failed"])
|
||||
# Clear views and return if mount fails
|
||||
self.system_backups_frame.show(backup_path, [])
|
||||
self.user_backups_frame.show(backup_path, [])
|
||||
return
|
||||
# Refresh header status after successful mount
|
||||
self.app.header_frame.refresh_status()
|
||||
|
||||
pybackup_dir = os.path.join(backup_path, "pybackup")
|
||||
|
||||
if not os.path.isdir(pybackup_dir):
|
||||
@@ -168,7 +187,6 @@ class BackupContentFrame(ttk.Frame):
|
||||
self.system_backups_frame.show(backup_path, [])
|
||||
self.user_backups_frame.show(backup_path, [])
|
||||
|
||||
|
||||
config_key = "last_encrypted_backup_content_view" if self.viewing_encrypted else "last_backup_content_view"
|
||||
last_view = self.app.config_manager.get_setting(config_key, 0)
|
||||
self._switch_view(last_view)
|
||||
|
Reference in New Issue
Block a user