From 827f3a1e089d7ff2c43e8384324f662e2dc30f60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=C3=A9sir=C3=A9=20Werner=20Menrath?= Date: Fri, 5 Sep 2025 01:48:49 +0200 Subject: [PATCH] feat: Implement CLI script and key file support for automated backups - Introduces `pybackup-cli.py` as a command-line interface for non-interactive backups. - Adds support for LUKS key files in `EncryptionManager` for passwordless container operations. - Updates `BackupManager` to pass key file arguments to encryption routines. - Modifies `AdvancedSettingsFrame` to provide a GUI for creating and managing key files. - Integrates `pybackup-cli.py` and key file options into `schedule_job_dialog.py` for cronjob generation. --- backup_manager.py | 9 +-- core/encryption_manager.py | 92 +++++++++++++++++++++---- pybackup-cli.py | 96 +++++++++++++++++++++++++++ pyimage_ui/advanced_settings_frame.py | 78 +++++++++++++++++----- schedule_job_dialog.py | 81 +++++++++++++++++++--- 5 files changed, 314 insertions(+), 42 deletions(-) create mode 100644 pybackup-cli.py diff --git a/backup_manager.py b/backup_manager.py index 4af871c..491db2c 100644 --- a/backup_manager.py +++ b/backup_manager.py @@ -123,12 +123,13 @@ 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, is_encrypted: bool = False, mode: str = "incremental", password: str = None): + 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", password: Optional[str] = None, key_file: Optional[str] = None): self.is_system_process = is_system 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, is_encrypted, mode, password)) + queue, source_path, dest_path, is_system, is_dry_run, exclude_files, source_size, is_compressed, is_encrypted, mode, password, key_file)) thread.daemon = True thread.start() + return thread def _find_latest_backup(self, base_backup_path: str) -> Optional[str]: """Finds the most recent backup directory in a given path.""" @@ -199,13 +200,13 @@ 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, is_encrypted: bool, mode: str, password: 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, password: Optional[str], key_file: Optional[str]): try: mount_point = None if is_encrypted: base_dest_path = os.path.dirname(dest_path) 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) + mount_point = self.encryption_manager.setup_encrypted_backup(queue, base_dest_path, size_gb, password=password, key_file=key_file) if not mount_point: return diff --git a/core/encryption_manager.py b/core/encryption_manager.py index b862980..981122f 100644 --- a/core/encryption_manager.py +++ b/core/encryption_manager.py @@ -9,6 +9,7 @@ import stat import re from typing import Optional +from pbp_app_config import AppConfig from pyimage_ui.password_dialog import PasswordDialog class EncryptionManager: @@ -22,11 +23,60 @@ class EncryptionManager: self.service_id = "py-backup-encryption" self.session_password = None + def get_key_file_path(self, base_dest_path: str) -> str: + """Generates the standard path for the key file for a given destination.""" + key_filename = f"keyfile_{os.path.basename(base_dest_path.rstrip('/'))}.key" + return os.path.join(AppConfig.CONFIG_DIR, key_filename) + + 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") + container_path = os.path.join(pybackup_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) + 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}") + self.logger.log(f"Could not initialize keyring: {e}") return None except Exception as e: self.logger.log(f"Could not get password from keyring: {e}") @@ -78,14 +128,17 @@ class EncryptionManager: return password def is_container_mounted(self, base_dest_path: str) -> bool: - """Checks if the LUKS container for a given base path is currently mounted.""" mapper_name = f"pybackup_{os.path.basename(base_dest_path.rstrip('/'))}" mount_point = f"/mnt/{mapper_name}" return os.path.ismount(mount_point) - def unlock_container(self, base_dest_path: str, password: str) -> Optional[str]: + 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") container_path = os.path.join(pybackup_dir, "pybackup_encrypted.luks") if not os.path.exists(container_path): @@ -99,13 +152,18 @@ class EncryptionManager: 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} - echo -n '{password}' | cryptsetup luksOpen {container_path} {mapper_name} - + {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 or permissions.") + self.logger.log("Failed to unlock existing encrypted container. Check password/key file or permissions.") self.cleanup_encrypted_backup(base_dest_path) return None @@ -115,7 +173,7 @@ class EncryptionManager: 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: str) -> Optional[str]: + 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}") if not shutil.which("cryptsetup"): @@ -128,9 +186,9 @@ class EncryptionManager: mapper_name = f"pybackup_{os.path.basename(base_dest_path.rstrip('/'))}" mount_point = f"/mnt/{mapper_name}" - if not password: - self.logger.log("No password provided for encryption.") - queue.put(('error', "No password provided for encryption.")) + 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.")) return None if os.path.ismount(mount_point): @@ -139,15 +197,23 @@ class EncryptionManager: 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) + return self.unlock_container(base_dest_path, password=password, key_file=key_file) else: self.logger.log(f"Creating new 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}" + script = f""" mkdir -p {pybackup_dir} fallocate -l {size_gb}G {container_path} - echo -n '{password}' | cryptsetup luksFormat {container_path} - + {format_auth_part} mkdir -p {mount_point} - echo -n '{password}' | cryptsetup luksOpen {container_path} {mapper_name} - + {open_auth_part} mkfs.ext4 /dev/mapper/{mapper_name} mount /dev/mapper/{mapper_name} {mount_point} """ @@ -212,4 +278,4 @@ class EncryptionManager: return False finally: if script_path and os.path.exists(script_path): - os.remove(script_path) + os.remove(script_path) \ No newline at end of file diff --git a/pybackup-cli.py b/pybackup-cli.py new file mode 100644 index 0000000..84267b0 --- /dev/null +++ b/pybackup-cli.py @@ -0,0 +1,96 @@ +#!/usr/bin/python3 +import argparse +import sys +import os +from queue import Queue +import threading + +from backup_manager import BackupManager +from shared_libs.logger import app_logger +from pbp_app_config import AppConfig + +# A simple logger for the CLI that just prints to the console +class CliLogger: + def log(self, message): + print(f"[CLI] {message}") + + def init_logger(self, log_method): + pass # Not needed for CLI + +def main(): + parser = argparse.ArgumentParser(description="Py-Backup Command-Line Interface.") + parser.add_argument("--backup-type", choices=['user', 'system'], required=True, help="Type of backup to perform.") + parser.add_argument("--destination", required=True, help="Destination directory for the backup.") + parser.add_argument("--source", help="Source directory for user backup. Required for --backup-type user.") + parser.add_argument("--mode", choices=['full', 'incremental'], default='incremental', help="Mode for system backup.") + parser.add_argument("--encrypted", action='store_true', help="Flag to indicate the backup should be encrypted.") + parser.add_argument("--key-file", help="Path to the key file for unlocking an encrypted container.") + parser.add_argument("--password", help="Password for the encrypted container (use with caution). If --key-file is not provided, this will be used.") + parser.add_argument("--compressed", action='store_true', help="Flag to indicate the backup should be compressed.") + + args = parser.parse_args() + + if args.backup_type == 'user' and not args.source: + parser.error("--source is required for --backup-type 'user'.") + + if args.encrypted and not (args.key_file or args.password): + parser.error("For encrypted backups, either --key-file or --password must be provided.") + + cli_logger = CliLogger() + backup_manager = BackupManager(cli_logger) + queue = Queue() # Dummy queue for now, might be used for progress later + + source_path = "/" # Default for system backup + if args.backup_type == 'user': + source_path = args.source + if not os.path.isdir(source_path): + cli_logger.log(f"Error: Source path '{source_path}' does not exist or is not a directory.") + sys.exit(1) + + # Determine password or key_file to pass + auth_password = None + auth_key_file = None + if args.encrypted: + if args.key_file: + auth_key_file = args.key_file + if not os.path.exists(auth_key_file): + cli_logger.log(f"Error: Key file '{auth_key_file}' does not exist.") + sys.exit(1) + elif args.password: + auth_password = args.password + + cli_logger.log(f"Starting backup with the following configuration:") + cli_logger.log(f" Type: {args.backup_type}") + cli_logger.log(f" Source: {source_path}") + cli_logger.log(f" Destination: {args.destination}") + cli_logger.log(f" Mode: {args.mode}") + cli_logger.log(f" Encrypted: {args.encrypted}") + cli_logger.log(f" Compressed: {args.compressed}") + if auth_key_file: + cli_logger.log(f" Auth Method: Key File ({auth_key_file})") + elif auth_password: + cli_logger.log(f" Auth Method: Password (REDACTED)") + + # Call the backup manager + backup_thread = backup_manager.start_backup( + queue=queue, + source_path=source_path, + dest_path=args.destination, + is_system=(args.backup_type == 'system'), + is_dry_run=False, + exclude_files=None, # Excludes are handled by AppConfig.MANUAL_EXCLUDE_LIST_PATH + source_size=0, # Not accurately calculable in CLI without scanning, set to 0 + is_compressed=args.compressed, + is_encrypted=args.encrypted, + mode=args.mode, + password=auth_password, + key_file=auth_key_file + ) + + # Wait for the backup thread to complete + backup_thread.join() + + cli_logger.log("CLI backup process finished.") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/pyimage_ui/advanced_settings_frame.py b/pyimage_ui/advanced_settings_frame.py index 553d90f..c81d0d6 100644 --- a/pyimage_ui/advanced_settings_frame.py +++ b/pyimage_ui/advanced_settings_frame.py @@ -6,7 +6,8 @@ from pathlib import Path from pbp_app_config import AppConfig, Msg from shared_libs.animated_icon import AnimatedIcon from pyimage_ui.shared_logic import enforce_backup_type_exclusivity - +from shared_libs.message import MessageDialog +from pyimage_ui.password_dialog import PasswordDialog class AdvancedSettingsFrame(tk.Toplevel): def __init__(self, master, config_manager, app_instance, **kwargs): @@ -17,12 +18,10 @@ class AdvancedSettingsFrame(tk.Toplevel): self.app_instance = app_instance self.current_view_index = 0 - # --- Warning Label --- self.info_label = ttk.Label( self, text=Msg.STR["advanced_settings_warning"], wraplength=780, justify="left") self.info_label.pack(pady=10, fill=tk.X, padx=10) - # --- Navigation --- nav_frame = ttk.Frame(self) nav_frame.pack(fill=tk.X, padx=10, pady=5) @@ -53,11 +52,9 @@ class AdvancedSettingsFrame(tk.Toplevel): ttk.Separator(top_nav_frame, orient=tk.VERTICAL).pack( side=tk.LEFT, fill=tk.Y, padx=2) - # --- Container for the two views --- view_container = ttk.Frame(self) view_container.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) - # --- Treeview for system folder exclusion --- self.tree_frame = ttk.LabelFrame( view_container, text=Msg.STR["exclude_system_folders"], padding=10) @@ -71,12 +68,9 @@ class AdvancedSettingsFrame(tk.Toplevel): self.tree.column("name", anchor="center") self.tree.column("included", width=100, anchor="center") self.tree.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) - self.tree.tag_configure("backup_dest_exclude", foreground="gray") - self.tree.bind("", self._toggle_include_status) - # --- Manual Excludes Frame --- self.manual_excludes_frame = ttk.LabelFrame( view_container, text=Msg.STR["manual_excludes"], padding=10) @@ -89,7 +83,6 @@ class AdvancedSettingsFrame(tk.Toplevel): self.manual_excludes_frame, text=Msg.STR["delete"], command=self._delete_manual_exclude) delete_button.pack(pady=5) - # --- Animation Settings --- animation_frame = ttk.LabelFrame( self, text=Msg.STR["animation_settings_title"], padding=10) animation_frame.pack(fill=tk.X, padx=10, pady=5) @@ -116,7 +109,6 @@ class AdvancedSettingsFrame(tk.Toplevel): animation_frame.columnconfigure(1, weight=1) - # --- Backup Default Settings --- defaults_frame = ttk.LabelFrame( self, text="Backup Defaults", padding=10) defaults_frame.pack(fill=tk.X, padx=10, pady=5) @@ -142,6 +134,25 @@ class AdvancedSettingsFrame(tk.Toplevel): defaults_frame, text=Msg.STR["encryption_note_system_backup"], wraplength=750, justify="left") encryption_note.pack(anchor=tk.W, pady=5) + # --- Automation Settings --- + automation_frame = ttk.LabelFrame(self, text="Automation (Cronjob)", padding=10) + automation_frame.pack(fill=tk.X, padx=10, pady=5) + + key_file_button = ttk.Button(automation_frame, text="Create/Add Key File", command=self._create_key_file) + key_file_button.grid(row=0, column=0, padx=5, pady=5) + + self.key_file_status_var = tk.StringVar(value="Key file not created.") + key_file_status_label = ttk.Label(automation_frame, textvariable=self.key_file_status_var, foreground="gray") + key_file_status_label.grid(row=0, column=1, padx=5, pady=5, sticky="w") + + sudoers_info_text = (f"To run automated backups, an administrator must create a file in /etc/sudoers.d/\n" + f"with the following content (replace 'punix' with the correct username):\n" + f"punix ALL=(ALL) NOPASSWD: /path/to/pybackup-cli.py") # Path needs to be updated + sudoers_info_label = ttk.Label(automation_frame, text=sudoers_info_text, justify="left") + sudoers_info_label.grid(row=1, column=0, columnspan=2, sticky="w", padx=5, pady=5) + + automation_frame.columnconfigure(1, weight=1) + # --- Action Buttons --- button_frame = ttk.Frame(self) button_frame.pack(pady=10) @@ -155,9 +166,48 @@ class AdvancedSettingsFrame(tk.Toplevel): self._load_animation_settings() self._load_backup_defaults() self._load_manual_excludes() + self._update_key_file_status() self._switch_view(self.current_view_index) + def _create_key_file(self): + if not self.app_instance.destination_path: + MessageDialog(self, message_type="error", title="Error", text="Please select a backup destination first.") + return + + pybackup_dir = os.path.join(self.app_instance.destination_path, "pybackup") + container_path = os.path.join(pybackup_dir, "pybackup_encrypted.luks") + if not os.path.exists(container_path): + MessageDialog(self, message_type="error", title="Error", text="No encrypted container found at the destination.") + return + + # Prompt for the existing password to authorize adding a new key + password_dialog = PasswordDialog(self, title="Enter Existing Password", confirm=False) + password, _ = password_dialog.get_password() + + if not password: + return # User cancelled + + key_file_path = self.app_instance.backup_manager.encryption_manager.create_and_add_key_file(self.app_instance.destination_path, password) + + if key_file_path: + MessageDialog(self, message_type="info", title="Success", text=f"Key file created and added successfully!\nPath: {key_file_path}") + else: + MessageDialog(self, message_type="error", title="Error", text="Failed to create or add key file. See log for details.") + + self._update_key_file_status() + + def _update_key_file_status(self): + if not self.app_instance.destination_path: + self.key_file_status_var.set("Key file status unknown (no destination set).") + return + + key_file_path = self.app_instance.backup_manager.encryption_manager.get_key_file_path(self.app_instance.destination_path) + if os.path.exists(key_file_path): + self.key_file_status_var.set(f"Key file exists: {key_file_path}") + else: + self.key_file_status_var.set("Key file has not been created for this destination.") + def _switch_view(self, index): self.current_view_index = index self.update_nav_buttons(index) @@ -272,7 +322,6 @@ class AdvancedSettingsFrame(tk.Toplevel): item_values = items_to_display[item_path_str] tag = "yes" if item_values[0] == Msg.STR["yes"] else "no" - # Special tag for the backup destination, which is always excluded and read-only is_backup_dest = (self.app_instance and self.app_instance.destination_path and item_path_str == str(Path(f"/{self.app_instance.destination_path.strip('/').split('/')[0]}").absolute())) is_restore_src = (restore_src_path and @@ -314,10 +363,8 @@ class AdvancedSettingsFrame(tk.Toplevel): if self.app_instance: self.app_instance.update_backup_options_from_config() - # Destroy the old icon self.app_instance.animated_icon.destroy() - # Create a new one bg_color = self.app_instance.style.lookup('TFrame', 'background') backup_animation_type = self.backup_anim_var.get() @@ -328,11 +375,9 @@ class AdvancedSettingsFrame(tk.Toplevel): self.app_instance.animated_icon = AnimatedIcon( self.app_instance.action_frame, width=20, height=20, use_pillow=True, bg=bg_color, animation_type=initial_animation_type) - # Pack it in the correct order self.app_instance.animated_icon.pack( side=tk.LEFT, padx=5, before=self.app_instance.task_progress) - # Set the correct state self.app_instance.animated_icon.stop("DISABLE") self.app_instance.animated_icon.animation_type = backup_animation_type @@ -374,7 +419,6 @@ class AdvancedSettingsFrame(tk.Toplevel): for path in final_excludes: f.write(f"{path}\n") - # Save manual excludes with open(AppConfig.MANUAL_EXCLUDE_LIST_PATH, 'w') as f: for item in self.manual_excludes_listbox.get(0, tk.END): f.write(f"{item}\n") @@ -405,4 +449,4 @@ class AdvancedSettingsFrame(tk.Toplevel): user_patterns.extend( [line.strip() for line in f if line.strip() and not line.startswith('#')]) - return generated_patterns, user_patterns + return generated_patterns, user_patterns \ No newline at end of file diff --git a/schedule_job_dialog.py b/schedule_job_dialog.py index a7fb351..5412251 100644 --- a/schedule_job_dialog.py +++ b/schedule_job_dialog.py @@ -15,7 +15,7 @@ class ScheduleJobDialog(tk.Toplevel): self.result = None self.title(Msg.STR["add_job_title"]) - self.geometry("500x400") + self.geometry("550x550") # Increased size self.transient(parent) self.grab_set() @@ -28,6 +28,8 @@ class ScheduleJobDialog(tk.Toplevel): Msg.STR["cat_videos"]: tk.BooleanVar(value=False) } self.frequency = tk.StringVar(value="daily") + self.use_key_file = tk.BooleanVar(value=False) + self.key_file_path = tk.StringVar() self._create_widgets() @@ -64,6 +66,17 @@ class ScheduleJobDialog(tk.Toplevel): variable=var).pack(anchor=tk.W) self._toggle_user_sources() # Set initial visibility + # Key File Options + key_file_frame = ttk.LabelFrame(main_frame, text="Key File for Encrypted Backups", padding=10) + key_file_frame.pack(fill=tk.X, padx=5, pady=5) + + ttk.Checkbutton(key_file_frame, text="Use Key File for automated access", variable=self.use_key_file, command=self._toggle_key_file_entry).pack(anchor=tk.W) + + self.key_file_entry = ttk.Entry(key_file_frame, textvariable=self.key_file_path, state="disabled", width=50) + self.key_file_entry.pack(side=tk.LEFT, fill=tk.X, expand=True) + self.key_file_browse_button = ttk.Button(key_file_frame, text=Msg.STR["browse"], command=self._select_key_file, state="disabled") + self.key_file_browse_button.pack(side=tk.RIGHT) + # Frequency freq_frame = ttk.LabelFrame( main_frame, text=Msg.STR["frequency"], padding=10) @@ -89,6 +102,11 @@ class ScheduleJobDialog(tk.Toplevel): else: self.user_sources_frame.pack_forget() + def _toggle_key_file_entry(self): + state = "normal" if self.use_key_file.get() else "disabled" + self.key_file_entry.config(state=state) + self.key_file_browse_button.config(state=state) + def _select_destination(self): dialog = CustomFileDialog( self, mode="dir", title=Msg.STR["select_dest_folder_title"]) @@ -97,6 +115,15 @@ class ScheduleJobDialog(tk.Toplevel): if result: self.destination.set(result) + def _select_key_file(self): + dialog = CustomFileDialog( + self, mode="file", title="Select Key File", filetypes=[("Key Files", "*.key"), ("All Files", "*.*")] + ) + self.wait_window(dialog) + result = dialog.get_result() + if result: + self.key_file_path.set(result) + def _on_save(self): dest = self.destination.get() if not dest: @@ -116,19 +143,57 @@ class ScheduleJobDialog(tk.Toplevel): title=Msg.STR["error"], text=Msg.STR["err_no_source_folder"]) return + # Check if destination is an encrypted container + is_encrypted_container = os.path.exists(os.path.join(dest, "pybackup", "pybackup_encrypted.luks")) + + if is_encrypted_container and not self.use_key_file.get(): + MessageDialog(master=self, message_type="error", + title=Msg.STR["error"], text="For encrypted destinations, 'Use Key File' must be selected for automated backups.") + return + + if self.use_key_file.get() and not self.key_file_path.get(): + MessageDialog(master=self, message_type="error", + title=Msg.STR["error"], text="Please select a key file path.") + return + # Construct the CLI command script_path = os.path.abspath(os.path.join( - os.path.dirname(__file__), "main_app.py")) + os.path.dirname(__file__), "pybackup-cli.py")) # Call the CLI script command = f"python3 {script_path} --backup-type {job_type} --destination \"{dest}\"" + if job_type == "user": - command += f" --sources " - for s in job_sources: - command += f'\"{s}\" ' + # For CLI, we assume a single source path for simplicity + if len(job_sources) > 1: + MessageDialog(master=self, message_type="warning", + title="Warning", text="For user backups, only the first selected source will be used in the automated job.") + if job_sources: + # Use the actual path from AppConfig.FOLDER_PATHS + source_folder_name = job_sources[0] + source_path = str(AppConfig.FOLDER_PATHS.get(source_folder_name, "")) + if not source_path: + MessageDialog(master=self, message_type="error", + title=Msg.STR["error"], text=f"Unknown source folder: {source_folder_name}") + return + command += f" --source \"{source_path}\"" + else: + MessageDialog(master=self, message_type="error", + title=Msg.STR["error"], text="Please select a source folder for user backup.") + return + + if is_encrypted_container: + command += " --encrypted" + if self.use_key_file.get(): + command += f" --key-file \"{self.key_file_path.get()}\"" + # No --password option for cronjobs, as it's insecure # Construct the cron job comment comment = f"{self.backup_manager.app_tag}; type:{job_type}; freq:{job_frequency}; dest:{dest}" if job_type == "user": - comment += f"; sources:{','.join(job_sources)}" + comment += f"; source:{source_path}" + if is_encrypted_container: + comment += "; encrypted" + if self.use_key_file.get(): + comment += "; key_file" self.result = { "command": command, @@ -136,7 +201,7 @@ class ScheduleJobDialog(tk.Toplevel): "type": job_type, "frequency": job_frequency, "destination": dest, - "sources": job_sources + "sources": job_sources # Keep original sources for display if needed } self.destroy() @@ -146,4 +211,4 @@ class ScheduleJobDialog(tk.Toplevel): def show(self): self.parent.wait_window(self) - return self.result + return self.result \ No newline at end of file