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.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
96
pybackup-cli.py
Normal file
96
pybackup-cli.py
Normal file
@@ -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()
|
||||
@@ -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("<Button-1>", 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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user