encrypted backups part one

This commit is contained in:
2025-09-04 01:49:24 +02:00
parent 9a7470f017
commit 158bc6ec97
7 changed files with 406 additions and 51 deletions

View File

@@ -9,8 +9,11 @@ from pathlib import Path
from crontab import CronTab
import tempfile
import stat
import shutil
from pbp_app_config import AppConfig
from pyimage_ui.password_dialog import PasswordDialog
from core.encryption_manager import EncryptionManager
class BackupManager:
@@ -18,52 +21,15 @@ class BackupManager:
Handles the logic for creating and managing backups using rsync.
"""
def __init__(self, logger):
def __init__(self, logger, app=None):
self.logger = logger
self.process = None
self.app_tag = "# Py-Backup Job"
self.is_system_process = False
self.app = app
self.encryption_manager = EncryptionManager(logger, app)
def _execute_as_root(self, script_content: str) -> bool:
"""Executes a shell script with root privileges using pkexec."""
script_path = ''
try:
# Use tempfile for secure temporary file creation
with tempfile.NamedTemporaryFile(mode='w', delete=False, prefix="pybackup_script_", suffix=".sh", dir="/tmp") as tmp_script:
tmp_script.write("#!/bin/bash\n\n")
tmp_script.write("set -e\n\n") # Exit on error
tmp_script.write(script_content)
script_path = tmp_script.name
# Make the script executable
os.chmod(script_path, stat.S_IRWXU | stat.S_IRGRP |
stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH)
command = ['pkexec', script_path]
self.logger.log(
f"Executing privileged command via script: {script_path}")
self.logger.log(
f"Script content:\n---\n{script_content}\n---")
result = subprocess.run(
command, capture_output=True, text=True, check=False)
if result.returncode == 0:
self.logger.log(
f"Privileged script executed successfully. Output:\n{result.stdout}")
return True
else:
self.logger.log(
f"Privileged script failed. Return code: {result.returncode}\nStderr: {result.stderr}\nStdout: {result.stdout}")
return False
except Exception as e:
self.logger.log(
f"Failed to set up or execute privileged command: {e}")
return False
finally:
if script_path and os.path.exists(script_path):
os.remove(script_path)
def cancel_and_delete_privileged_backup(self, delete_path: str):
"""Cancels a running system backup and deletes the target directory in one atomic pkexec call."""
@@ -162,10 +128,10 @@ rm -f '{info_file}'
else:
self.logger.log("No active backup process to cancel.")
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, mode: str = "incremental"):
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"):
"""Starts a generic backup process for a specific path, reporting to a queue."""
thread = threading.Thread(target=self._run_backup_path, args=(
queue, source_path, dest_path, is_system, is_dry_run, exclude_files, source_size, is_compressed, mode))
queue, source_path, dest_path, is_system, is_dry_run, exclude_files, source_size, is_compressed, is_encrypted, mode))
thread.daemon = True
thread.start()
@@ -241,9 +207,23 @@ set -e
self.logger.log(f"An unexpected error occurred during local compression/cleanup: {e}")
return False
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, mode: str):
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):
try:
self.is_system_process = is_system
if is_encrypted:
# For encrypted backups, the dest_path is the container file.
container_path = dest_path + ".luks"
# Estimate container size to be 110% of source size
size_gb = int(source_size / (1024**3) * 1.1) + 1
mount_point = self.encryption_manager.setup_encrypted_backup(queue, container_path, size_gb)
if not mount_point:
return # Error or cancellation already handled in setup method
# The actual destination for rsync is the mount point
rsync_dest = mount_point
else:
rsync_dest = dest_path
self.logger.log(
f"Starting backup from '{source_path}' to '{dest_path}'...")
@@ -280,7 +260,7 @@ set -e
if is_dry_run:
command.append('--dry-run')
command.extend([source_path, dest_path])
command.extend([source_path, rsync_dest])
self.logger.log(f"Rsync command: {' '.join(command)}")
transferred_size, total_size = self._execute_rsync(queue, command)
@@ -341,6 +321,8 @@ set -e
self.logger.log(
f"Backup to '{dest_path}' completed.")
finally:
if is_encrypted and 'mount_point' in locals() and mount_point:
self.encryption_manager.cleanup_encrypted_backup(f"pybackup_{os.path.basename(dest_path + '.luks')}", mount_point)
self.process = None
def _create_info_file(self, dest_path: str, filename: str, source_size: int):
@@ -633,7 +615,7 @@ set -e
# Regex to parse folder names like '6-März-2024_143000_system_full' or '6-März-2024_143000_system_full.tar.gz'
name_regex = re.compile(
r"^(\d{1,2}-\w+-\d{4})_(\d{6})_system_(full|incremental)(\.tar\.gz)?$", re.IGNORECASE)
r"^(\d{1,2}-\w+-\d{4})_(\d{6})_system_(full|incremental)(\.tar\.gz|\.luks)?$", re.IGNORECASE)
for item in os.listdir(pybackup_path):
# Skip info files
@@ -648,11 +630,15 @@ set -e
date_str = match.group(1)
# time_str = match.group(2) # Not currently used in UI, but available
backup_type_base = match.group(3).capitalize()
is_compressed = match.group(4) is not None
extension = match.group(4)
is_compressed = (extension == ".tar.gz")
is_encrypted = (extension == ".luks")
backup_type = backup_type_base
if is_compressed:
backup_type += " (Compressed)"
elif is_encrypted:
backup_type += " (Encrypted)"
backup_size = "N/A"
comment = ""
@@ -682,7 +668,8 @@ set -e
"folder_name": item,
"full_path": full_path,
"comment": comment,
"is_compressed": is_compressed
"is_compressed": is_compressed,
"is_encrypted": is_encrypted
})
# Sort by parsing the date from the folder name

179
core/encryption_manager.py Normal file
View File

@@ -0,0 +1,179 @@
import keyring
import keyring.errors
from keyring.backends import SecretService
import os
import shutil
import subprocess
import tempfile
import stat
from typing import Optional
from pyimage_ui.password_dialog import PasswordDialog
class EncryptionManager:
def __init__(self, logger, app=None):
self.logger = logger
self.app = app
self.service_id = "py-backup-encryption"
self.session_password = None
self.save_to_keyring = False
def get_password_from_keyring(self, username: str) -> Optional[str]:
try:
return keyring.get_password(self.service_id, username)
except keyring.errors.InitError as e:
self.logger.log(f"Could not initialize keyring. Keyring is not available on this system or is not configured correctly. Error: {e}")
return None
except Exception as e:
self.logger.log(f"Could not get password from keyring: {e}")
return None
def set_password_in_keyring(self, username: str, password: str):
try:
keyring.set_password(self.service_id, username, password)
self.logger.log(f"Password for {username} stored in keyring.")
except Exception as e:
self.logger.log(f"Could not set password in keyring: {e}")
def delete_password_from_keyring(self, username: str):
try:
keyring.delete_password(self.service_id, username)
self.logger.log(f"Password for {username} deleted from keyring.")
except Exception as e:
self.logger.log(f"Could not delete password from keyring: {e}")
def set_session_password(self, password: str, save_to_keyring: bool):
self.session_password = password
self.save_to_keyring = save_to_keyring
def clear_session_password(self):
self.session_password = None
self.save_to_keyring = False
def get_password(self, username: str, confirm: bool = True) -> Optional[str]:
if self.session_password:
if self.save_to_keyring:
self.set_password_in_keyring(username, self.session_password)
return self.session_password
password = self.get_password_from_keyring(username)
if password:
return password
# If not in keyring, prompt the user
dialog = PasswordDialog(self.app, title=f"Enter password for {username}", confirm=confirm)
password = dialog.get_password()
if password:
# Ask to save the password
# For now, we don't save it automatically. This will be a UI option.
pass
return password
def setup_encrypted_backup(self, queue, container_path: str, size_gb: int) -> Optional[str]:
"""Sets up a LUKS encrypted container for the backup."""
self.logger.log(f"Setting up encrypted container at {container_path}")
if not shutil.which("cryptsetup"):
self.logger.log("Error: cryptsetup is not installed.")
queue.put(('error', "cryptsetup is not installed."))
return None
mapper_name = f"pybackup_{os.path.basename(container_path)}"
mount_point = f"/mnt/{mapper_name}"
if os.path.exists(container_path):
# Container exists, try to open it
self.logger.log(f"Encrypted container {container_path} already exists. Attempting to unlock.")
password = self.get_password(os.path.basename(container_path), confirm=False)
if not password:
self.logger.log("User cancelled password entry.")
queue.put(('completion', {'status': 'cancelled', 'returncode': -1}))
return None
script = f"""
echo -n '{password}' | cryptsetup luksOpen {container_path} {mapper_name} -
mkdir -p {mount_point}
mount /dev/mapper/{mapper_name} {mount_point}
"""
if not self._execute_as_root(script):
self.logger.log("Failed to unlock existing encrypted container.")
queue.put(('error', "Failed to unlock existing encrypted container."))
return None
else:
# Container does not exist, create it
password = self.get_password(os.path.basename(container_path), confirm=True)
if not password:
self.logger.log("User cancelled password entry.")
queue.put(('completion', {'status': 'cancelled', 'returncode': -1}))
return None
script = f"""
fallocate -l {size_gb}G {container_path}
echo -n '{password}' | cryptsetup luksFormat {container_path} -
echo -n '{password}' | cryptsetup luksOpen {container_path} {mapper_name} -
mkfs.ext4 /dev/mapper/{mapper_name}
mkdir -p {mount_point}
mount /dev/mapper/{mapper_name} {mount_point}
"""
if not self._execute_as_root(script):
self.logger.log("Failed to setup encrypted container.")
self._cleanup_encrypted_backup(mapper_name, mount_point)
queue.put(('error', "Failed to setup encrypted container."))
return None
self.logger.log(f"Encrypted container is ready and mounted at {mount_point}")
return mount_point
def cleanup_encrypted_backup(self, mapper_name: str, mount_point: str):
"""Unmounts and closes the LUKS container."""
self.logger.log(f"Cleaning up encrypted backup: {mapper_name}")
script = f"""
umount {mount_point} || echo "Mount point not found or already unmounted."
cryptsetup luksClose {mapper_name} || echo "Mapper not found or already closed."
rmdir {mount_point} || echo "Mount point directory not found."
"""
if not self._execute_as_root(script):
self.logger.log("Encrypted backup cleanup script failed.")
def _execute_as_root(self, script_content: str) -> bool:
"""Executes a shell script with root privileges using pkexec."""
script_path = ''
try:
# Use tempfile for secure temporary file creation
with tempfile.NamedTemporaryFile(mode='w', delete=False, prefix="pybackup_script_", suffix=".sh", dir="/tmp") as tmp_script:
tmp_script.write("#!/bin/bash\n\n")
tmp_script.write("set -e\n\n") # Exit on error
tmp_script.write(script_content)
script_path = tmp_script.name
# Make the script executable
os.chmod(script_path, stat.S_IRWXU | stat.S_IRGRP |
stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH)
command = ['pkexec', script_path]
self.logger.log(
f"Executing privileged command via script: {script_path}")
self.logger.log(
f"Script content:\n---\n{script_content}\n---")
result = subprocess.run(
command, capture_output=True, text=True, check=False)
if result.returncode == 0:
self.logger.log(
f"Privileged script executed successfully. Output:\n{result.stdout}")
return True
else:
self.logger.log(
f"Privileged script failed. Return code: {result.returncode}\nStderr: {result.stderr}\nStdout: {result.stdout}")
return False
except Exception as e:
self.logger.log(
f"Failed to set up or execute privileged command: {e}")
return False
finally:
if script_path and os.path.exists(script_path):
os.remove(script_path)

View File

@@ -16,6 +16,7 @@ from pyimage_ui.scheduler_frame import SchedulerFrame
from pyimage_ui.backup_content_frame import BackupContentFrame
from pyimage_ui.header_frame import HeaderFrame
from pyimage_ui.settings_frame import SettingsFrame
from pyimage_ui.encryption_frame import EncryptionFrame
from core.data_processing import DataProcessing
from pyimage_ui.drawing import Drawing
from pyimage_ui.navigation import Navigation
@@ -69,7 +70,7 @@ class MainApplication(tk.Tk):
self.content_frame.grid_rowconfigure(6, weight=0)
self.content_frame.grid_columnconfigure(0, weight=1)
self.backup_manager = BackupManager(app_logger)
self.backup_manager = BackupManager(app_logger, self)
self.queue = Queue()
self.image_manager = IconManager()
@@ -79,6 +80,7 @@ class MainApplication(tk.Tk):
self.navigation = Navigation(self)
self.actions = Actions(self)
self.mode = "backup" # Default mode
self.backup_is_running = False
self.start_time = None
@@ -142,6 +144,10 @@ 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.encryption_button = ttk.Button(
self.sidebar_buttons_frame, text="Encryption", command=lambda: self.navigation.toggle_encryption_frame(5), style="Sidebar.TButton")
self.encryption_button.pack(fill=tk.X, pady=10)
self.header_frame = HeaderFrame(self.content_frame, self.image_manager)
self.header_frame.grid(row=0, column=0, sticky="nsew")
@@ -216,6 +222,7 @@ class MainApplication(tk.Tk):
self._setup_scheduler_frame()
self._setup_settings_frame()
self._setup_backup_content_frame()
self._setup_encryption_frame()
self._setup_task_bar()
@@ -400,6 +407,12 @@ class MainApplication(tk.Tk):
self.backup_content_frame.grid(row=2, column=0, sticky="nsew")
self.backup_content_frame.grid_remove()
def _setup_encryption_frame(self):
self.encryption_frame = EncryptionFrame(
self.content_frame, self.backup_manager.encryption_manager, padding=10)
self.encryption_frame.grid(row=2, column=0, sticky="nsew")
self.encryption_frame.grid_remove()
def _setup_task_bar(self):
# Define all boolean vars at the top to ensure they exist before use.
self.vollbackup_var = tk.BooleanVar()
@@ -459,7 +472,7 @@ class MainApplication(tk.Tk):
variable=self.compressed_var, command=self.actions.handle_compression_change)
self.compressed_cb.pack(side=tk.LEFT, padx=5)
self.encrypted_cb = ttk.Checkbutton(checkbox_frame, text=Msg.STR["encrypted"],
variable=self.encrypted_var)
variable=self.encrypted_var, command=self.actions.handle_encryption_change)
self.encrypted_cb.pack(side=tk.LEFT, padx=5)
self.test_run_cb = ttk.Checkbutton(checkbox_frame, text=Msg.STR["test_run"],
variable=self.testlauf_var)

View File

@@ -88,6 +88,23 @@ class Actions:
# The state of the incremental checkbox depends on whether a full backup exists
self._update_backup_type_controls()
def handle_encryption_change(self):
if self.app.encrypted_var.get():
# Encryption is enabled, force full backup and disable compression
self.app.vollbackup_var.set(True)
self.app.inkrementell_var.set(False)
self.app.full_backup_cb.config(state="disabled")
self.app.incremental_cb.config(state="disabled")
self.app.compressed_var.set(False)
self.app.compressed_cb.config(state="disabled")
self.app.accurate_size_cb.config(state="disabled")
self.app.genaue_berechnung_var.set(False)
else:
# Encryption is disabled, restore normal logic
self.app.full_backup_cb.config(state="normal")
self.app.compressed_cb.config(state="normal")
self._update_backup_type_controls()
def on_toggle_accurate_size_calc(self):
# This method is called when the user clicks the "Genaue inkrem. Größe" checkbox.
if not self.app.genaue_berechnung_var.get():
@@ -673,6 +690,7 @@ class Actions:
is_dry_run = self.app.testlauf_var.get()
is_compressed = self.app.compressed_var.get()
is_encrypted = self.app.encrypted_var.get()
self.app.backup_manager.start_backup(
queue=self.app.queue,
@@ -683,6 +701,7 @@ class Actions:
exclude_files=exclude_file_paths,
source_size=source_size_bytes,
is_compressed=is_compressed,
is_encrypted=is_encrypted,
mode=mode)
def _start_user_backup(self, sources):

View File

@@ -0,0 +1,75 @@
import tkinter as tk
from tkinter import ttk
from shared_libs.message import MessageDialog
import keyring
class EncryptionFrame(ttk.Frame):
def __init__(self, parent, encryption_manager, **kwargs):
super().__init__(parent, **kwargs)
self.encryption_manager = encryption_manager
self.columnconfigure(0, weight=1)
ttk.Label(self, text="Encryption Settings", font=("Ubuntu", 16, "bold")).grid(
row=0, column=0, pady=10, sticky="w")
# Keyring status
self.keyring_status_label = ttk.Label(self, text="")
self.keyring_status_label.grid(
row=1, column=0, sticky="ew", padx=10, pady=5)
self.check_keyring_availability()
# Password section
password_frame = ttk.LabelFrame(self, text="Password Management")
password_frame.grid(row=2, column=0, sticky="ew", padx=10, pady=10)
password_frame.columnconfigure(1, weight=1)
ttk.Label(password_frame, text="Password:").grid(
row=0, column=0, padx=5, pady=5, sticky="w")
self.password_entry = ttk.Entry(password_frame, show="*")
self.password_entry.grid(row=0, column=1, padx=5, pady=5, sticky="ew")
self.save_to_keyring_var = tk.BooleanVar()
self.save_to_keyring_cb = ttk.Checkbutton(
password_frame, text="Save password to system keyring", variable=self.save_to_keyring_var)
self.save_to_keyring_cb.grid(
row=1, column=0, columnspan=2, padx=5, pady=5, sticky="w")
set_password_button = ttk.Button(
password_frame, text="Set Session Password", command=self.set_session_password)
set_password_button.grid(row=2, column=0, padx=5, pady=5)
clear_password_button = ttk.Button(
password_frame, text="Clear Session Password", command=self.clear_session_password)
clear_password_button.grid(row=2, column=1, padx=5, pady=5, sticky="w")
self.status_message_label = ttk.Label(self, text="", foreground="blue")
self.status_message_label.grid(row=3, column=0, sticky="ew", padx=10, pady=5)
def check_keyring_availability(self):
try:
kr = keyring.get_keyring()
if kr is None:
self.keyring_status_label.config(
text="No system keyring found. Passwords will not be saved.", foreground="orange")
self.save_to_keyring_cb.config(state="disabled")
except keyring.errors.NoKeyringError:
self.keyring_status_label.config(
text="No system keyring found. Passwords will not be saved.", foreground="orange")
self.save_to_keyring_cb.config(state="disabled")
def set_session_password(self):
password = self.password_entry.get()
if not password:
self.status_message_label.config(text="Password cannot be empty.", foreground="red")
return
self.encryption_manager.set_session_password(password, self.save_to_keyring_var.get())
self.status_message_label.config(text="Password set for this session.", foreground="green")
def clear_session_password(self):
self.encryption_manager.clear_session_password()
self.password_entry.delete(0, tk.END)
self.status_message_label.config(text="Session password cleared.", foreground="green")

View File

@@ -142,6 +142,7 @@ class Navigation:
self.app.scheduler_frame.hide()
self.app.settings_frame.hide()
self.app.backup_content_frame.hide()
self.app.encryption_frame.grid_remove()
# Show the main content frames
self.app.canvas_frame.grid()
@@ -186,6 +187,7 @@ class Navigation:
self.app.scheduler_frame.hide()
self.app.settings_frame.hide()
self.app.backup_content_frame.hide()
self.app.encryption_frame.grid_remove()
self.app.canvas_frame.grid()
self.app.source_size_frame.grid()
self.app.target_size_frame.grid()
@@ -240,6 +242,7 @@ class Navigation:
self.app.log_frame.grid_remove()
self.app.settings_frame.hide()
self.app.backup_content_frame.hide()
self.app.encryption_frame.grid_remove()
self.app.source_size_frame.grid_remove()
self.app.target_size_frame.grid_remove()
self.app.restore_size_frame_before.grid_remove()
@@ -257,6 +260,7 @@ class Navigation:
self.app.log_frame.grid_remove()
self.app.backup_content_frame.hide()
self.app.scheduler_frame.hide()
self.app.encryption_frame.grid_remove()
self.app.source_size_frame.grid_remove()
self.app.target_size_frame.grid_remove()
self.app.restore_size_frame_before.grid_remove()
@@ -280,6 +284,7 @@ class Navigation:
self.app.log_frame.grid_remove()
self.app.scheduler_frame.hide()
self.app.settings_frame.hide()
self.app.encryption_frame.grid_remove()
self.app.source_size_frame.grid_remove()
self.app.target_size_frame.grid_remove()
self.app.restore_size_frame_before.grid_remove()
@@ -287,3 +292,20 @@ class Navigation:
self.app.backup_content_frame.show(self.app.destination_path)
self.app.top_bar.grid()
self._update_task_bar_visibility("scheduler")
def toggle_encryption_frame(self, active_index=None):
self._cancel_calculation()
self.app.drawing.update_nav_buttons(-1)
self.app.canvas_frame.grid_remove()
self.app.log_frame.grid_remove()
self.app.scheduler_frame.hide()
self.app.settings_frame.hide()
self.app.backup_content_frame.hide()
self.app.source_size_frame.grid_remove()
self.app.target_size_frame.grid_remove()
self.app.restore_size_frame_before.grid_remove()
self.app.restore_size_frame_after.grid_remove()
self.app.encryption_frame.grid()
self.app.top_bar.grid()
self._update_task_bar_visibility("settings")

View File

@@ -0,0 +1,60 @@
import tkinter as tk
from tkinter import ttk, messagebox
class PasswordDialog(tk.Toplevel):
def __init__(self, parent, title="Password Required", confirm=True):
super().__init__(parent)
self.title(title)
self.parent = parent
self.password = None
self.confirm = confirm
self.transient(parent)
self.grab_set()
ttk.Label(self, text="Please enter the password for the encrypted backup:").pack(padx=20, pady=10)
self.password_entry = ttk.Entry(self, show="*")
self.password_entry.pack(padx=20, pady=5, fill="x", expand=True)
self.password_entry.focus_set()
if self.confirm:
ttk.Label(self, text="Confirm password:").pack(padx=20, pady=10)
self.confirm_entry = ttk.Entry(self, show="*")
self.confirm_entry.pack(padx=20, pady=5, fill="x", expand=True)
button_frame = ttk.Frame(self)
button_frame.pack(pady=10)
ok_button = ttk.Button(button_frame, text="OK", command=self.on_ok)
ok_button.pack(side="left", padx=5)
cancel_button = ttk.Button(button_frame, text="Cancel", command=self.on_cancel)
cancel_button.pack(side="left", padx=5)
self.bind("<Return>", lambda event: self.on_ok())
self.bind("<Escape>", lambda event: self.on_cancel())
self.wait_window(self)
def on_ok(self):
password = self.password_entry.get()
if not password:
messagebox.showerror("Error", "Password cannot be empty.", parent=self)
return
if self.confirm:
confirm = self.confirm_entry.get()
if password != confirm:
messagebox.showerror("Error", "Passwords do not match.", parent=self)
return
self.password = password
self.destroy()
def on_cancel(self):
self.password = None
self.destroy()
def get_password(self):
return self.password