From 38c090e2571f6148dc8e1225da6be1074b08578e Mon Sep 17 00:00:00 2001 From: Fuxino Date: Sun, 28 May 2023 21:30:40 +0200 Subject: [PATCH 01/23] Add basic remote backup functionality --- simple_backup.conf | 20 ++- simple_backup/__init__.py | 2 +- simple_backup/simple_backup.py | 240 ++++++++++++++++++++++++--------- 3 files changed, 192 insertions(+), 70 deletions(-) diff --git a/simple_backup.conf b/simple_backup.conf index 6adc9a4..1b64aab 100644 --- a/simple_backup.conf +++ b/simple_backup.conf @@ -1,14 +1,20 @@ -#Example config file for simple_backup +# Example config file for simple_backup -[default] -#Input directories. Use a comma to separate items +[backup] +# Input directories. Use a comma to separate items inputs=/home/my_home,/etc -#Output directory +# Output directory backup_dir=/media/Backup -#Exclude patterns. Use a comma to separate items +# Exclude patterns. Use a comma to separate items exclude=.gvfs,.local/share/gvfs-metadata,.cache,.dbus,.Trash,.local/share/Trash,.macromedia,.adobe,.recently-used,.recently-used.xbel,.thumbnails -#Number of snapshots to keep (use -1 to keep all) -keep=-1 \ No newline at end of file +# Number of snapshots to keep (use -1 to keep all) +keep=-1 + +# Uncomment the following section to enable backup to remote server through ssh +#[server] +#host= +#username= +#ssh_keyfile= \ No newline at end of file diff --git a/simple_backup/__init__.py b/simple_backup/__init__.py index 3b5a160..e7673f0 100644 --- a/simple_backup/__init__.py +++ b/simple_backup/__init__.py @@ -1,3 +1,3 @@ """Init.""" -__version__ = '3.2.5' +__version__ = '4.0.0a' diff --git a/simple_backup/simple_backup.py b/simple_backup/simple_backup.py index b349f19..78e6ff1 100755 --- a/simple_backup/simple_backup.py +++ b/simple_backup/simple_backup.py @@ -1,6 +1,7 @@ #!/usr/bin/python3 # Import libraries +import sys import os from functools import wraps from shutil import rmtree @@ -12,7 +13,12 @@ from timeit import default_timer from subprocess import Popen, PIPE, STDOUT from datetime import datetime from tempfile import mkstemp +from getpass import getpass + from dotenv import load_dotenv +import paramiko +from paramiko import RSAKey, Ed25519Key, ECDSAKey, DSSKey + try: from systemd import journal @@ -76,17 +82,22 @@ class MyFormatter(argparse.RawTextHelpFormatter, argparse.ArgumentDefaultsHelpFo class Backup: - def __init__(self, inputs, output, exclude, keep, options, remove_before=False): + def __init__(self, inputs, output, exclude, keep, options, host=None, + username=None, ssh_keyfile=None, remove_before=False): self.inputs = inputs self.output = output self.exclude = exclude self.options = options self.keep = keep + self.host = host + self.username = username + self.ssh_keyfile = ssh_keyfile self.remove_before = remove_before self._last_backup = '' self._output_dir = '' self._inputs_path = '' self._exclude_path = '' + self._remote = None self._err_flag = False def check_params(self): @@ -100,10 +111,17 @@ class Backup: return False - if not os.path.isdir(self.output): - logger.critical('Output path for backup does not exist') + if self.host is not None and self.username is not None: + self._remote = True - return False + if self._remote: + # TODO + pass + else: + if not os.path.isdir(self.output): + logger.critical('Output path for backup does not exist') + + return False self.output = os.path.abspath(self.output) @@ -113,52 +131,115 @@ class Backup: return True # Function to create the actual backup directory - def create_backup_dir(self): + def define_backup_dir(self): now = datetime.now().strftime('%Y-%m-%d %H:%M:%S') self._output_dir = f'{self.output}/simple_backup/{now}' - os.makedirs(self._output_dir, exist_ok=True) + if self._remote: + self._output_dir = f'{self.username}@{self.host}:{self._output_dir}' def remove_old_backups(self): - try: - dirs = os.listdir(f'{self.output}/simple_backup') - except FileNotFoundError: - return + if self._remote: + # TODO + count = 0 + pass + else: + try: + dirs = os.listdir(f'{self.output}/simple_backup') + except FileNotFoundError: + return - if dirs.count('last_backup') > 0: - dirs.remove('last_backup') + if dirs.count('last_backup') > 0: + dirs.remove('last_backup') - n_backup = len(dirs) - 1 - count = 0 + n_backup = len(dirs) - 1 + count = 0 - if n_backup > self.keep: - logger.info('Removing old backups...') - dirs.sort() + if n_backup > self.keep: + logger.info('Removing old backups...') + dirs.sort() - for i in range(n_backup - self.keep): - try: - rmtree(f'{self.output}/simple_backup/{dirs[i]}') - count += 1 - except FileNotFoundError: - logger.error(f'Error while removing backup {dirs[i]}. Directory not found') - except PermissionError: - logger.error(f'Error while removing backup {dirs[i]}. Permission denied') + for i in range(n_backup - self.keep): + try: + rmtree(f'{self.output}/simple_backup/{dirs[i]}') + count += 1 + except FileNotFoundError: + logger.error(f'Error while removing backup {dirs[i]}. Directory not found') + except PermissionError: + logger.error(f'Error while removing backup {dirs[i]}. Permission denied') - if count == 1: - logger.info(f'Removed {count} backup') - else: - logger.info(f'Removed {count} backups') + if count == 1: + logger.info(f'Removed {count} backup') + elif count > 1: + logger.info(f'Removed {count} backups') def find_last_backup(self): - if os.path.islink(f'{self.output}/simple_backup/last_backup'): - link = os.readlink(f'{self.output}/simple_backup/last_backup') + if self._remote: + # TODO + pass + else: + if os.path.islink(f'{self.output}/simple_backup/last_backup'): + link = os.readlink(f'{self.output}/simple_backup/last_backup') - if os.path.isdir(link): - self._last_backup = link + if os.path.isdir(link): + self._last_backup = link + else: + logger.info('No previous backups available') else: logger.info('No previous backups available') - else: - logger.info('No previous backups available') + + def _ssh_connection(self): + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + + pkey = None + password = None + + try: + pkey = RSAKey.from_private_key_file(self.ssh_keyfile) + except paramiko.PasswordRequiredException: + password = getpass() + + try: + pkey = RSAKey.from_private_key_file(self.ssh_keyfile, password) + except paramiko.SSHException: + pass + + if pkey is None: + try: + pkey = Ed25519Key.from_private_key_file(self.ssh_keyfile) + except paramiko.PasswordRequiredException: + try: + pkey = Ed25519Key.from_private_key_file(self.ssh_keyfile, password) + except paramiko.SSHException: + pass + + if pkey is None: + try: + pkey = ECDSAKey.from_private_key_file(self.ssh_keyfile) + except paramiko.PasswordRequiredException: + try: + pkey = ECDSAKey.from_private_key_file(self.ssh_keyfile, password) + except paramiko.SSHException: + pass + + if pkey is None: + try: + pkey = DSSKey.from_private_key_file(self.ssh_keyfile) + except paramiko.PasswordRequiredException: + try: + pkey = DSSKey.from_private_key_file(self.ssh_keyfile, password) + except paramiko.SSHException: + pass + + try: + ssh.connect(self.host, username=self.username, pkey=pkey) + except paramiko.SSHException as e: + logger.error(e) + + return None + + return ssh # Function to read configuration file @timing(logger) @@ -170,7 +251,7 @@ class Backup: except NameError: pass - self.create_backup_dir() + self.define_backup_dir() self.find_last_backup() _, self._inputs_path = mkstemp(prefix='tmp_inputs', text=True) @@ -197,13 +278,14 @@ class Backup: if self._last_backup == '': rsync = f'rsync {self.options} --exclude-from={self._exclude_path} ' +\ f'--files-from={self._inputs_path} / "{self._output_dir}" ' +\ - '--ignore-missing-args' + '--ignore-missing-args --mkpath --protect-args' else: rsync = f'rsync {self.options} --link-dest="{self._last_backup}" --exclude-from=' +\ f'{self._exclude_path} --files-from={self._inputs_path} / "{self._output_dir}" ' +\ - '--ignore-missing-args' + '--ignore-missing-args --mkpath --protect-args' + + p = Popen(rsync, stdin=PIPE, stdout=PIPE, stderr=STDOUT, shell=True) - p = Popen(rsync, stdout=PIPE, stderr=STDOUT, shell=True) output, _ = p.communicate() if p.returncode != 0: @@ -214,24 +296,36 @@ class Backup: logger.info(f'rsync: {output[-3]}') logger.info(f'rsync: {output[-2]}') - if os.path.islink(f'{self.output}/simple_backup/last_backup'): + if self._remote: + # TODO + pass + else: + if os.path.islink(f'{self.output}/simple_backup/last_backup'): + try: + os.remove(f'{self.output}/simple_backup/last_backup') + except FileNotFoundError: + logger.error('Failed to remove last_backup link. File not found') + self._err_flag = True + except PermissionError: + logger.error('Failed to remove last_backup link. Permission denied') + self._err_flag = True + + if self._remote: + # TODO + pass + else: try: - os.remove(f'{self.output}/simple_backup/last_backup') - except FileNotFoundError: - logger.error('Failed to remove last_backup link. File not found') + os.symlink(self._output_dir, f'{self.output}/simple_backup/last_backup', target_is_directory=True) + except FileExistsError: + logger.error('Failed to create last_backup link. Link already exists') self._err_flag = True except PermissionError: - logger.error('Failed to remove last_backup link. Permission denied') + logger.error('Failed to create last_backup link. Permission denied') self._err_flag = True + except FileNotFoundError: + logger.critical('Failed to create backup') - try: - os.symlink(self._output_dir, f'{self.output}/simple_backup/last_backup', target_is_directory=True) - except FileExistsError: - logger.error('Failed to create last_backup link. Link already exists') - self._err_flag = True - except PermissionError: - logger.error('Failed to create last_backup link. Permission denied') - self._err_flag = True + return 1 if self.keep != -1 and not self.remove_before: self.remove_old_backups() @@ -251,6 +345,8 @@ class Backup: else: notify('Backup finished') + return 0 + def _parse_arguments(): parser = argparse.ArgumentParser(prog='simple_backup', @@ -264,6 +360,9 @@ def _parse_arguments(): parser.add_argument('-o', '--output', help='Output directory for the backup') parser.add_argument('-e', '--exclude', nargs='+', help='Files/directories/patterns to exclude from the backup') parser.add_argument('-k', '--keep', type=int, help='Number of old backups to keep') + parser.add_argument('--host', help='Server hostname (for remote backup)') + parser.add_argument('-u', '--username', help='Username to connect to server (for remote backup)') + parser.add_argument('--keyfile', help='SSH key location') parser.add_argument('-s', '--checksum', action='store_true', help='Use checksum rsync option to compare files (MUCH SLOWER)') parser.add_argument('--remove-before-backup', action='store_true', @@ -283,14 +382,23 @@ def _read_config(config_file): config = configparser.ConfigParser() config.read(config_file) - inputs = config.get('default', 'inputs') + inputs = config.get('backup', 'inputs') inputs = inputs.split(',') - output = config.get('default', 'backup_dir') - exclude = config.get('default', 'exclude') + output = config.get('backup', 'backup_dir') + exclude = config.get('backup', 'exclude') exclude = exclude.split(',') - keep = config.getint('default', 'keep') + keep = config.getint('backup', 'keep') - return inputs, output, exclude, keep + try: + host = config.get('server', 'host') + username = config.get('server', 'username') + ssh_keyfile = config.get('server', 'ssh_keyfile') + except (configparser.NoSectionError, configparser.NoOptionError): + host = None + username = None + ssh_keyfile = None + + return inputs, output, exclude, keep, host, username, ssh_keyfile def notify(text): @@ -313,7 +421,7 @@ def notify(text): def simple_backup(): args = _parse_arguments() - inputs, output, exclude, keep = _read_config(args.config) + inputs, output, exclude, keep, username, host, ssh_keyfile = _read_config(args.config) if args.input is not None: inputs = args.input @@ -327,17 +435,25 @@ def simple_backup(): if args.keep is not None: keep = args.keep + if args.host is not None: + host = args.host + + if args.username is not None: + username = args.username + + if args.keyfile is not None: + ssh_keyfile = args.keyfile + if args.checksum: backup_options = '-arcvh -H -X' else: backup_options = '-arvh -H -X' - backup = Backup(inputs, output, exclude, keep, backup_options, remove_before=args.remove_before_backup) + backup = Backup(inputs, output, exclude, keep, backup_options, host, username, + ssh_keyfile, remove_before=args.remove_before_backup) if backup.check_params(): - backup.run() - - return 0 + return backup.run() return 1 From 88e6a9a141df75ba76b1e0855efc2e5643cb1712 Mon Sep 17 00:00:00 2001 From: Fuxino Date: Sun, 28 May 2023 23:19:08 +0200 Subject: [PATCH 02/23] Add incremental backups on server --- simple_backup/simple_backup.py | 53 +++++++++++++++++++++++++++------- 1 file changed, 42 insertions(+), 11 deletions(-) diff --git a/simple_backup/simple_backup.py b/simple_backup/simple_backup.py index 78e6ff1..a03df79 100755 --- a/simple_backup/simple_backup.py +++ b/simple_backup/simple_backup.py @@ -94,11 +94,13 @@ class Backup: self.ssh_keyfile = ssh_keyfile self.remove_before = remove_before self._last_backup = '' + self._server = '' self._output_dir = '' self._inputs_path = '' self._exclude_path = '' self._remote = None self._err_flag = False + self._ssh = None def check_params(self): if self.inputs is None or len(self.inputs) == 0: @@ -115,8 +117,14 @@ class Backup: self._remote = True if self._remote: - # TODO - pass + self._ssh = self._ssh_connection() + + _, stdout, _ = self._ssh.exec_command(f'if [ -d "{self.output}" ]; then echo "ok"; fi') + + if stdout.read().decode('utf-8').strip() != 'ok': + logger.critical('Output path for backup does not exist') + + return False else: if not os.path.isdir(self.output): logger.critical('Output path for backup does not exist') @@ -136,7 +144,7 @@ class Backup: self._output_dir = f'{self.output}/simple_backup/{now}' if self._remote: - self._output_dir = f'{self.username}@{self.host}:{self._output_dir}' + self._server = f'{self.username}@{self.host}:' def remove_old_backups(self): if self._remote: @@ -175,8 +183,22 @@ class Backup: def find_last_backup(self): if self._remote: - # TODO - pass + if self._ssh is None: + logger.critical('SSH connection to server failed') + sys.exit(1) + + _, stdout, _ = self._ssh.exec_command(f'readlink -v {self.output}/simple_backup/last_backup') + last_backup = stdout.read().decode('utf-8').strip() + + if last_backup != '': + _, stdout, _ = self._ssh.exec_command(f'if [ -d "{last_backup}" ]; then echo "ok"; fi') + + if stdout.read().decode('utf-8').strip() == 'ok': + self._last_backup = last_backup + else: + logger.info('No previous backups available') + else: + logger.info('No previous backups available') else: if os.path.islink(f'{self.output}/simple_backup/last_backup'): link = os.readlink(f'{self.output}/simple_backup/last_backup') @@ -277,11 +299,11 @@ class Backup: if self._last_backup == '': rsync = f'rsync {self.options} --exclude-from={self._exclude_path} ' +\ - f'--files-from={self._inputs_path} / "{self._output_dir}" ' +\ + f'--files-from={self._inputs_path} / "{self._server}{self._output_dir}" ' +\ '--ignore-missing-args --mkpath --protect-args' else: rsync = f'rsync {self.options} --link-dest="{self._last_backup}" --exclude-from=' +\ - f'{self._exclude_path} --files-from={self._inputs_path} / "{self._output_dir}" ' +\ + f'{self._exclude_path} --files-from={self._inputs_path} / "{self._server}{self._output_dir}" ' +\ '--ignore-missing-args --mkpath --protect-args' p = Popen(rsync, stdin=PIPE, stdout=PIPE, stderr=STDOUT, shell=True) @@ -297,8 +319,14 @@ class Backup: logger.info(f'rsync: {output[-2]}') if self._remote: - # TODO - pass + _, stdout, _ = self._ssh.exec_command(f'if [ -L "{self.output}/simple_backup/last_backup" ]; then echo "ok"; fi') + + if stdout.read().decode('utf-8').strip() == 'ok': + _, _, stderr = self._ssh.exec_command(f'rm "{self.output}/simple_backup/last_backup"') + + if stderr.read().decode('utf-8').strip() != '': + logger.error(stderr.read().decode('utf-8')) + self._err_flag = True else: if os.path.islink(f'{self.output}/simple_backup/last_backup'): try: @@ -311,8 +339,11 @@ class Backup: self._err_flag = True if self._remote: - # TODO - pass + _, _, stderr = self._ssh.exec_command(f'ln -s "{self._output_dir}" "{self.output}/simple_backup/last_backup"') + + if stderr.read().decode('utf-8').strip() != '': + logger.error(stderr.read().decode('utf-8')) + self._err_flag = True else: try: os.symlink(self._output_dir, f'{self.output}/simple_backup/last_backup', target_is_directory=True) From 24a59bde2dba13a3545ae4eda937da8a884155eb Mon Sep 17 00:00:00 2001 From: Fuxino Date: Mon, 29 May 2023 00:09:54 +0200 Subject: [PATCH 03/23] Use ssh agent if available --- simple_backup/simple_backup.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/simple_backup/simple_backup.py b/simple_backup/simple_backup.py index a03df79..b4fdd78 100755 --- a/simple_backup/simple_backup.py +++ b/simple_backup/simple_backup.py @@ -119,6 +119,9 @@ class Backup: if self._remote: self._ssh = self._ssh_connection() + if self._ssh is None: + sys.exit(1) + _, stdout, _ = self._ssh.exec_command(f'if [ -d "{self.output}" ]; then echo "ok"; fi') if stdout.read().decode('utf-8').strip() != 'ok': @@ -214,9 +217,24 @@ class Backup: ssh = paramiko.SSHClient() ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + agent = paramiko.Agent() + agent_keys = agent.get_keys() + + for key in agent_keys: + try: + ssh.connect(self.host, username=self.username, pkey=key) + return ssh + except paramiko.SSHException: + pass + pkey = None password = None + if self.ssh_keyfile is None: + logger.critical('Can\'t connect to the server. No key specified') + + return None + try: pkey = RSAKey.from_private_key_file(self.ssh_keyfile) except paramiko.PasswordRequiredException: From c25ef52393b285a55deeba3eadf7d45a9b92f2bb Mon Sep 17 00:00:00 2001 From: Fuxino Date: Mon, 29 May 2023 17:57:12 +0200 Subject: [PATCH 04/23] Implement remove old backups from server --- setup.cfg | 2 +- simple_backup/__init__.py | 2 +- simple_backup/simple_backup.py | 63 ++++++++++++++++++++++++++-------- 3 files changed, 51 insertions(+), 16 deletions(-) diff --git a/setup.cfg b/setup.cfg index 1cfd233..b4a0c87 100644 --- a/setup.cfg +++ b/setup.cfg @@ -8,7 +8,7 @@ author_email = dfucini@gmail.com license = GPL3 url = https://github.com/Fuxino/simple_backup classifiers = - Development Status :: 5 - Production/Stable + Development Status :: 4 - Beta Environment :: Console License :: OSI Approved :: GNU General Public License v3 (GPLv3) Natural Language :: English diff --git a/simple_backup/__init__.py b/simple_backup/__init__.py index e7673f0..9fcd51a 100644 --- a/simple_backup/__init__.py +++ b/simple_backup/__init__.py @@ -1,3 +1,3 @@ """Init.""" -__version__ = '4.0.0a' +__version__ = '4.0.0b' diff --git a/simple_backup/simple_backup.py b/simple_backup/simple_backup.py index b4fdd78..ee0d0c2 100755 --- a/simple_backup/simple_backup.py +++ b/simple_backup/simple_backup.py @@ -124,7 +124,9 @@ class Backup: _, stdout, _ = self._ssh.exec_command(f'if [ -d "{self.output}" ]; then echo "ok"; fi') - if stdout.read().decode('utf-8').strip() != 'ok': + output = stdout.read().decode('utf-8').strip() + + if output != 'ok': logger.critical('Output path for backup does not exist') return False @@ -151,9 +153,30 @@ class Backup: def remove_old_backups(self): if self._remote: - # TODO + _, stdout, _ = self._ssh.exec_command(f'ls {self.output}/simple_backup') + + dirs = stdout.read().decode('utf-8').strip().split('\n') + + if dirs.count('last_backup') > 0: + dirs.remove('last_backup') + + n_backup = len(dirs) - 1 count = 0 - pass + + if n_backup > self.keep: + logger.info('Removing old backups...') + dirs.sort() + + for i in range(n_backup - self.keep): + _, _, stderr = self._ssh.exec_command(f'rm -r "{self.output}/simple_backup/{dirs[i]}"') + + err = stderr.read().decode('utf-8').strip().split('\n')[0] + + if err != '': + logger.error(f'Error while removing backup {dirs[i]}.') + logger.error(err) + else: + count += 1 else: try: dirs = os.listdir(f'{self.output}/simple_backup') @@ -196,7 +219,9 @@ class Backup: if last_backup != '': _, stdout, _ = self._ssh.exec_command(f'if [ -d "{last_backup}" ]; then echo "ok"; fi') - if stdout.read().decode('utf-8').strip() == 'ok': + output = stdout.read().decode('utf-8').strip() + + if output == 'ok': self._last_backup = last_backup else: logger.info('No previous backups available') @@ -333,17 +358,25 @@ class Backup: output = output.decode("utf-8").split('\n') - logger.info(f'rsync: {output[-3]}') - logger.info(f'rsync: {output[-2]}') + if self._err_flag: + logger.error(f'rsync: {output[-3]}') + logger.error(f'rsync: {output[-2]}') + else: + logger.info(f'rsync: {output[-3]}') + logger.info(f'rsync: {output[-2]}') if self._remote: _, stdout, _ = self._ssh.exec_command(f'if [ -L "{self.output}/simple_backup/last_backup" ]; then echo "ok"; fi') - if stdout.read().decode('utf-8').strip() == 'ok': + output = stdout.read().decode('utf-8').strip() + + if output == 'ok': _, _, stderr = self._ssh.exec_command(f'rm "{self.output}/simple_backup/last_backup"') - if stderr.read().decode('utf-8').strip() != '': - logger.error(stderr.read().decode('utf-8')) + err = stderr.read().decode('utf-8').strip() + + if err != '': + logger.error(err) self._err_flag = True else: if os.path.islink(f'{self.output}/simple_backup/last_backup'): @@ -356,13 +389,15 @@ class Backup: logger.error('Failed to remove last_backup link. Permission denied') self._err_flag = True - if self._remote: + if self._remote and not self._err_flag: _, _, stderr = self._ssh.exec_command(f'ln -s "{self._output_dir}" "{self.output}/simple_backup/last_backup"') - if stderr.read().decode('utf-8').strip() != '': - logger.error(stderr.read().decode('utf-8')) + err = stderr.read().decode('utf-8').strip() + + if err != '': + logger.error(err) self._err_flag = True - else: + elif not self._remote: try: os.symlink(self._output_dir, f'{self.output}/simple_backup/last_backup', target_is_directory=True) except FileExistsError: @@ -385,7 +420,7 @@ class Backup: logger.info('Backup completed') if self._err_flag: - logger.warning('Some errors occurred (check log for details)') + logger.warning('Some errors occurred') try: notify('Backup finished with errors (check log for details)') From 7eb71bc9247d6207089a1ef18c2fbc8378a8514b Mon Sep 17 00:00:00 2001 From: Fuxino Date: Mon, 29 May 2023 18:33:02 +0200 Subject: [PATCH 05/23] Add rsync compress option --- simple_backup/simple_backup.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/simple_backup/simple_backup.py b/simple_backup/simple_backup.py index ee0d0c2..d68dc1d 100755 --- a/simple_backup/simple_backup.py +++ b/simple_backup/simple_backup.py @@ -5,6 +5,7 @@ import sys import os from functools import wraps from shutil import rmtree +import shlex import argparse import configparser import logging @@ -341,15 +342,16 @@ class Backup: logger.info('Copying files. This may take a long time...') if self._last_backup == '': - rsync = f'rsync {self.options} --exclude-from={self._exclude_path} ' +\ + rsync = f'/usr/bin/rsync {self.options} --exclude-from={self._exclude_path} ' +\ f'--files-from={self._inputs_path} / "{self._server}{self._output_dir}" ' +\ '--ignore-missing-args --mkpath --protect-args' else: - rsync = f'rsync {self.options} --link-dest="{self._last_backup}" --exclude-from=' +\ + rsync = f'/usr/bin/rsync {self.options} --link-dest="{self._last_backup}" --exclude-from=' +\ f'{self._exclude_path} --files-from={self._inputs_path} / "{self._server}{self._output_dir}" ' +\ '--ignore-missing-args --mkpath --protect-args' - p = Popen(rsync, stdin=PIPE, stdout=PIPE, stderr=STDOUT, shell=True) + args = shlex.split(rsync) + p = Popen(args, stdin=PIPE, stdout=PIPE, stderr=STDOUT, shell=False) output, _ = p.communicate() @@ -448,7 +450,8 @@ def _parse_arguments(): parser.add_argument('-u', '--username', help='Username to connect to server (for remote backup)') parser.add_argument('--keyfile', help='SSH key location') parser.add_argument('-s', '--checksum', action='store_true', - help='Use checksum rsync option to compare files (MUCH SLOWER)') + help='Use checksum rsync option to compare files') + parser.add_argument('-z', '--compress', action='store_true', help='Compress data during the transfer') parser.add_argument('--remove-before-backup', action='store_true', help='Remove old backups before executing the backup, instead of after') @@ -528,10 +531,15 @@ def simple_backup(): if args.keyfile is not None: ssh_keyfile = args.keyfile + backup_options = ['-a', '-r', '-v', '-h', '-H', '-X'] + if args.checksum: - backup_options = '-arcvh -H -X' - else: - backup_options = '-arvh -H -X' + backup_options.append('-c') + + if args.compress: + backup_options.append('-z') + + backup_options = ' '.join(backup_options) backup = Backup(inputs, output, exclude, keep, backup_options, host, username, ssh_keyfile, remove_before=args.remove_before_backup) From df18e383ed8072507856f8448d3b85b356f45ba6 Mon Sep 17 00:00:00 2001 From: Fuxino Date: Mon, 29 May 2023 19:02:05 +0200 Subject: [PATCH 06/23] Update dependencies --- PKGBUILD | 3 ++- setup.cfg | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/PKGBUILD b/PKGBUILD index 6e03e11..2cf6242 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -17,7 +17,8 @@ makedepends=('python-setuptools' 'python-wheel') depends=('python' 'rsync' - 'python-dotenv') + 'python-dotenv' + 'python-paramiko') optdepends=('python-systemd: use systemd log' 'python-dbus: for desktop notifications') conflicts=('simple_backup-git') diff --git a/setup.cfg b/setup.cfg index b4a0c87..231899e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -26,6 +26,7 @@ python_requires = >=3.7 install_requires = python-dotenv systemd-python + paramiko [options.entry_points] console_scripts = From 335ad348e51f3cd3d02bd52486b04a19984743be Mon Sep 17 00:00:00 2001 From: Fuxino Date: Mon, 29 May 2023 21:56:58 +0200 Subject: [PATCH 07/23] Update README.md --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index 09632a5..266b844 100644 --- a/README.md +++ b/README.md @@ -45,3 +45,12 @@ For Arch Linux, a PKGBUILD that automates this process is provided. After installing, copy simple_backup.conf (if you used the PKGBUILD on Arch, it will be in /etc/simple_backup/) to $HOME/.config/simple_backup and edit is as needed. +## Remote backup +> **Warning** +> This feature is experimental + +It's possible to use a remote server as destination for the backup. Just use the --username and --host arguments (or set them in the configuration file). +For this to work, rsync must be installed on the server too. + +### Server authentication +Right now only authentication using SSH key works. If an ssh-agent is running on your system, available ssh keys will be used automatically. Otherwise, it's possible to specify the key location. Note that if no ssh agent is running, it might be necessary to unlock the private key more than once. From ba97b25e25dc084ff2c27682f4514f34f7af9ff6 Mon Sep 17 00:00:00 2001 From: Fuxino Date: Mon, 29 May 2023 23:10:29 +0200 Subject: [PATCH 08/23] Add docstrings --- .gitignore | 1 + simple_backup/simple_backup.py | 121 +++++++++++++++++++++++++-------- 2 files changed, 93 insertions(+), 29 deletions(-) diff --git a/.gitignore b/.gitignore index f26b8e6..e2f2aaa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .idea/ +__pycache__/ test/ diff --git a/simple_backup/simple_backup.py b/simple_backup/simple_backup.py index d68dc1d..a9ba23d 100755 --- a/simple_backup/simple_backup.py +++ b/simple_backup/simple_backup.py @@ -1,4 +1,15 @@ -#!/usr/bin/python3 +#!/usr/bin/env python3 + +""" +A simple python script that calls rsync to perform a backup + +Parameters can be specified on the command line or using a configuration file +Backup to a remote server is also supported (experimental) + +Classes: + MyFormatter + Backup +""" # Import libraries import sys @@ -59,6 +70,11 @@ if journal: def timing(_logger): + """Decorator to measure execution time of a function + + Parameters: + _logger: Logger object + """ def decorator_timing(func): @wraps(func) def wrapper_timing(*args, **kwargs): @@ -78,10 +94,44 @@ def timing(_logger): class MyFormatter(argparse.RawTextHelpFormatter, argparse.ArgumentDefaultsHelpFormatter): - pass + """Custom format for argparse help text""" class Backup: + """Main class defining parameters and functions for performing backup + + Attributes: + inputs: list + Files and folders that will be backup up + output: str + Path where the backup will be saved + exclude: list + List of files/folders/patterns to exclude from backup + options: str + String representing main backup options for rsync + keep: int + Number of old backup to preserve + host: str + Hostname of server (for remote backup) + username: str + Username for server login (for remote backup) + ssh_keyfile: str + Location of ssh key + remove_before: bool + Indicate if removing old backups will be performed before copying files + + Methods: + check_params(): + Check if parameters for the backup are valid + define_backup_dir(): + Define the actual backup dir + remove_old_backups(): + Remove old backups if there are more than indicated by 'keep' + find_last_backup(): + Get path of last backup (from last_backup symlink) for rsync --link-dest + run(): + Perform the backup + """ def __init__(self, inputs, output, exclude, keep, options, host=None, username=None, ssh_keyfile=None, remove_before=False): @@ -104,6 +154,8 @@ class Backup: self._ssh = None def check_params(self): + """Check if parameters for the backup are valid""" + if self.inputs is None or len(self.inputs) == 0: logger.info('No files or directory specified for backup.') @@ -146,6 +198,7 @@ class Backup: # Function to create the actual backup directory def define_backup_dir(self): + """Define the actual backup dir""" now = datetime.now().strftime('%Y-%m-%d %H:%M:%S') self._output_dir = f'{self.output}/simple_backup/{now}' @@ -153,6 +206,8 @@ class Backup: self._server = f'{self.username}@{self.host}:' def remove_old_backups(self): + """Remove old backups if there are more than indicated by 'keep'""" + if self._remote: _, stdout, _ = self._ssh.exec_command(f'ls {self.output}/simple_backup') @@ -174,7 +229,7 @@ class Backup: err = stderr.read().decode('utf-8').strip().split('\n')[0] if err != '': - logger.error(f'Error while removing backup {dirs[i]}.') + logger.error('Error while removing backup %s.', {dirs[i]}) logger.error(err) else: count += 1 @@ -199,16 +254,18 @@ class Backup: rmtree(f'{self.output}/simple_backup/{dirs[i]}') count += 1 except FileNotFoundError: - logger.error(f'Error while removing backup {dirs[i]}. Directory not found') + logger.error('Error while removing backup %s. Directory not found', dirs[i]) except PermissionError: - logger.error(f'Error while removing backup {dirs[i]}. Permission denied') + logger.error('Error while removing backup %s. Permission denied', dirs[i]) if count == 1: - logger.info(f'Removed {count} backup') + logger.info('Removed %d backup', count) elif count > 1: - logger.info(f'Removed {count} backups') + logger.info('Removed %d backups', count) def find_last_backup(self): + """Get path of last backup (from last_backup symlink) for rsync --link-dest""" + if self._remote: if self._ssh is None: logger.critical('SSH connection to server failed') @@ -310,10 +367,12 @@ class Backup: # Function to read configuration file @timing(logger) def run(self): + """Perform the backup""" + logger.info('Starting backup...') try: - notify('Starting backup...') + _notify('Starting backup...') except NameError: pass @@ -323,15 +382,15 @@ class Backup: _, self._inputs_path = mkstemp(prefix='tmp_inputs', text=True) _, self._exclude_path = mkstemp(prefix='tmp_exclude', text=True) - with open(self._inputs_path, 'w') as fp: + with open(self._inputs_path, 'w', encoding='utf-8') as fp: for i in self.inputs: if not os.path.exists(i): - logger.warning(f'Input {i} not found. Skipping') + logger.warning('Input %s not found. Skipping', i) else: fp.write(i) fp.write('\n') - with open(self._exclude_path, 'w') as fp: + with open(self._exclude_path, 'w', encoding='utf-8') as fp: for e in self.exclude: fp.write(e) fp.write('\n') @@ -351,24 +410,25 @@ class Backup: '--ignore-missing-args --mkpath --protect-args' args = shlex.split(rsync) - p = Popen(args, stdin=PIPE, stdout=PIPE, stderr=STDOUT, shell=False) - output, _ = p.communicate() + with Popen(args, stdin=PIPE, stdout=PIPE, stderr=STDOUT, shell=False) as p: + output, _ = p.communicate() - if p.returncode != 0: - self._err_flag = True + if p.returncode != 0: + self._err_flag = True output = output.decode("utf-8").split('\n') if self._err_flag: - logger.error(f'rsync: {output[-3]}') - logger.error(f'rsync: {output[-2]}') + logger.error('rsync: %s', output[-3]) + logger.error('rsync: %s', output[-2]) else: - logger.info(f'rsync: {output[-3]}') - logger.info(f'rsync: {output[-2]}') + logger.info('rsync: %s', output[-3]) + logger.info('rsync: %s', output[-2]) if self._remote: - _, stdout, _ = self._ssh.exec_command(f'if [ -L "{self.output}/simple_backup/last_backup" ]; then echo "ok"; fi') + _, stdout, _ = \ + self._ssh.exec_command(f'if [ -L "{self.output}/simple_backup/last_backup" ]; then echo "ok"; fi') output = stdout.read().decode('utf-8').strip() @@ -392,7 +452,8 @@ class Backup: self._err_flag = True if self._remote and not self._err_flag: - _, _, stderr = self._ssh.exec_command(f'ln -s "{self._output_dir}" "{self.output}/simple_backup/last_backup"') + _, _, stderr =\ + self._ssh.exec_command(f'ln -s "{self._output_dir}" "{self.output}/simple_backup/last_backup"') err = stderr.read().decode('utf-8').strip() @@ -425,11 +486,11 @@ class Backup: logger.warning('Some errors occurred') try: - notify('Backup finished with errors (check log for details)') + _notify('Backup finished with errors (check log for details)') except NameError: pass else: - notify('Backup finished') + _notify('Backup finished') return 0 @@ -462,9 +523,9 @@ def _parse_arguments(): def _read_config(config_file): if not os.path.isfile(config_file): - logger.warning(f'Config file {config_file} does not exist') + logger.warning('Config file %s does not exist', config_file) - return None, None, None, None + return None, None, None, None, None, None, None config = configparser.ConfigParser() config.read(config_file) @@ -488,10 +549,10 @@ def _read_config(config_file): return inputs, output, exclude, keep, host, username, ssh_keyfile -def notify(text): - euid = os.geteuid() +def _notify(text): + _euid = os.geteuid() - if euid == 0: + if _euid == 0: uid = os.getenv('SUDO_UID') else: uid = os.geteuid() @@ -503,10 +564,12 @@ def notify(text): obj = dbus.Interface(obj, 'org.freedesktop.Notifications') obj.Notify('', 0, '', 'simple_backup', text, [], {'urgency': 1}, 10000) - os.seteuid(int(euid)) + os.seteuid(int(_euid)) def simple_backup(): + """Main""" + args = _parse_arguments() inputs, output, exclude, keep, username, host, ssh_keyfile = _read_config(args.config) From b957200debf0cf85658fef3e0c1ab812156b38eb Mon Sep 17 00:00:00 2001 From: Fuxino Date: Wed, 31 May 2023 19:30:31 +0200 Subject: [PATCH 09/23] Change missing hostkey policy --- simple_backup/simple_backup.py | 41 +++++++++++++++++++++++----------- 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/simple_backup/simple_backup.py b/simple_backup/simple_backup.py index 5fb203e..5c98c33 100755 --- a/simple_backup/simple_backup.py +++ b/simple_backup/simple_backup.py @@ -14,6 +14,7 @@ Classes: # Import libraries import sys import os +import warnings from functools import wraps from shutil import rmtree import shlex @@ -31,6 +32,8 @@ from dotenv import load_dotenv import paramiko from paramiko import RSAKey, Ed25519Key, ECDSAKey, DSSKey +warnings.filterwarnings('error') + try: from systemd import journal @@ -298,30 +301,42 @@ class Backup: def _ssh_connection(self): ssh = paramiko.SSHClient() - ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + ssh.load_system_host_keys() + ssh.set_missing_host_key_policy(paramiko.WarningPolicy()) - agent = paramiko.Agent() - agent_keys = agent.get_keys() + try: + ssh.connect(self.host, username=self.username) - for key in agent_keys: - try: - ssh.connect(self.host, username=self.username, pkey=key) - return ssh - except paramiko.SSHException: - pass + return ssh + except UserWarning: + k = input(f'Unknown key for host {self.host}. Continue anyway? (Y/N) ') + + if k[0].upper() == 'Y': + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + else: + return None + except paramiko.SSHException: + pass + + try: + ssh.connect(self.host, username=self.username) + + return ssh + except paramiko.SSHException: + pass pkey = None password = None if self.ssh_keyfile is None: - logger.critical('Can\'t connect to the server. No key specified') + logger.critical('Can\'t connect to the server. No authentication method available') return None try: pkey = RSAKey.from_private_key_file(self.ssh_keyfile) except paramiko.PasswordRequiredException: - password = getpass() + password = getpass(f'Enter passwphrase for key \'{self.ssh_keyfile}\': ') try: pkey = RSAKey.from_private_key_file(self.ssh_keyfile, password) @@ -357,8 +372,8 @@ class Backup: try: ssh.connect(self.host, username=self.username, pkey=pkey) - except paramiko.SSHException as e: - logger.error(e) + except paramiko.SSHException: + logger.critical('SSH connection to server failed') return None From 98cb7f5822cdff5c48392314ab5a15487e4baf46 Mon Sep 17 00:00:00 2001 From: Fuxino Date: Wed, 31 May 2023 20:39:03 +0200 Subject: [PATCH 10/23] Fix ssh authentication when running with sudo --- README.md | 10 ++++++++-- simple_backup/simple_backup.py | 6 ++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 266b844..df0dcb2 100644 --- a/README.md +++ b/README.md @@ -49,8 +49,14 @@ After installing, copy simple_backup.conf (if you used the PKGBUILD on Arch, it > **Warning** > This feature is experimental -It's possible to use a remote server as destination for the backup. Just use the --username and --host arguments (or set them in the configuration file). +It's possible to use a remote server as destination for the backup. Just use the --username (or -u) and --host arguments (or set them in the configuration file). For this to work, rsync must be installed on the server too. ### Server authentication -Right now only authentication using SSH key works. If an ssh-agent is running on your system, available ssh keys will be used automatically. Otherwise, it's possible to specify the key location. Note that if no ssh agent is running, it might be necessary to unlock the private key more than once. +Right now only authentication using SSH key works. If an ssh-agent is running on your system, available ssh keys will be used automatically. Otherwise, it's possible to specify the key location with --keyfile or in the configuration file. Note that if no ssh agent is running, it might be necessary to unlock the private key more than once. + +To be able to connect to the user authentication agent when running simple_backup with sudo, use: + +```bash +sudo --preserve-env=SSH_AUTH_SOCK -s simple_backup [options] +``` diff --git a/simple_backup/simple_backup.py b/simple_backup/simple_backup.py index 5c98c33..06e42a5 100755 --- a/simple_backup/simple_backup.py +++ b/simple_backup/simple_backup.py @@ -425,6 +425,9 @@ class Backup: f'{self._exclude_path} --files-from={self._inputs_path} / "{self._server}{self._output_dir}" ' +\ '--ignore-missing-args --mkpath --protect-args' + if euid == 0 and self.ssh_keyfile is not None: + rsync = f'{rsync} -e \'ssh -i {self.ssh_keyfile}\'' + args = shlex.split(rsync) with Popen(args, stdin=PIPE, stdout=PIPE, stderr=STDOUT, shell=False) as p: @@ -436,8 +439,7 @@ class Backup: output = output.decode("utf-8").split('\n') if self._err_flag: - logger.error('rsync: %s', output[-3]) - logger.error('rsync: %s', output[-2]) + logger.error('rsync: %s', output) else: logger.info('rsync: %s', output[-3]) logger.info('rsync: %s', output[-2]) From 7664fa1b33e04c59930de65998c7467214af680c Mon Sep 17 00:00:00 2001 From: Fuxino Date: Wed, 31 May 2023 22:12:59 +0200 Subject: [PATCH 11/23] Update README.md --- README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index df0dcb2..f1a9306 100644 --- a/README.md +++ b/README.md @@ -53,10 +53,13 @@ It's possible to use a remote server as destination for the backup. Just use the For this to work, rsync must be installed on the server too. ### Server authentication -Right now only authentication using SSH key works. If an ssh-agent is running on your system, available ssh keys will be used automatically. Otherwise, it's possible to specify the key location with --keyfile or in the configuration file. Note that if no ssh agent is running, it might be necessary to unlock the private key more than once. +Right now only authentication using SSH key works. The best way to handle the authentication is to have an ssh agent running on your system, otherwise if a passphrase is necessary to unlock the ssh key, it will be necessary to enter it more than once. +If needed, it's possible to specify the ssh key location with the --keyfile argument or in the configuration file. -To be able to connect to the user authentication agent when running simple_backup with sudo, use: +To be able to connect to the user's ssh agent when running simple_backup with sudo, make sure to preserve the SSH_AUTH_SOCK environment variable. For example: ```bash sudo --preserve-env=SSH_AUTH_SOCK -s simple_backup [options] ``` + +or by editing the sudoers file. From 4f3d83f458f077bee3ce995fe231234b184ff6d6 Mon Sep 17 00:00:00 2001 From: Fuxino Date: Thu, 1 Jun 2023 22:18:32 +0200 Subject: [PATCH 12/23] Update manpage --- man/simple_backup.1 | 65 +++++++++++++++++++++++++--------- simple_backup/simple_backup.py | 2 +- 2 files changed, 50 insertions(+), 17 deletions(-) diff --git a/man/simple_backup.1 b/man/simple_backup.1 index c37cc03..98b7c61 100644 --- a/man/simple_backup.1 +++ b/man/simple_backup.1 @@ -16,14 +16,23 @@ simple_backup \- Backup files and folders using rsync .PD .RS 14 [\-e, \-\-exclude FILE|DIR|PATTERN [FILE|...]] [\-k, \-\-keep N] +[\-\-host HOSTNAME] +[\-u, \-\-username USERNAME] +[\-\-keyfile FILE] +.PD 0 +.P +.PD [\-s, \-\-checksum] +[\-z, \-\-compress] [\-\-remove\-before\-backup] .RE .SH DESCRIPTION .BR simple_backup -is a python script for performing backup of files and folders. It uses rsync to copy the files to the specified location. -Parameters for the backup such as input files/directories, output location and files or folders to exclude can be specified -in a configuration file (default location $HOME/.config/simple_backup/simple_backup.conf) or directly on the command line. +is a python script for performing backup of files and folders. It uses rsync to copy the files +to the specified location. Parameters for the backup such as input files/directories, output +location and files or folders to exclude can be specified +in a configuration file (default location $HOME/.config/simple_backup/simple_backup.conf) +or directly on the command line. Parameters specified on the command line will override those in the configuration file. .SH OPTIONS .TP @@ -34,31 +43,55 @@ Print a short help message and exit Specify the configuration file, useful to specify a different one from the default. .TP .B \-i, \-\-input INPUT [INPUT...] -Specify the files and directories to backup. Multiple inputs can be specified, just separate them with a space. -If filenames or paths contain spaces, don't forget to escape them, or to use single or double quotes around them. +Specify the files and directories to backup. Multiple inputs can be specified, just separate +them with a space. If filenames or paths contain spaces, don't forget to escape them, +or to use single or double quotes around them. .TP .B \-o, \-\-output DIR -Specify the directory where the files will be copied. The program will automatically create a subdirectory called -\(aqsimple_backup\(aq (if it does not already exist) and inside this directory the actual backup directory (using -the current date and time) +Specify the directory where the files will be copied. The program will automatically +create a subdirectory called \(aqsimple_backup\(aq (if it does not already exist) and +inside this directory the actual backup directory (using the current date and time) .TP .B \-e, \-\-exclude FILE|DIR|PATTERN [FILE|...]] -Specify files, directories or patterns to exclude from the backup. Matching files and directories will not be copied. -Multiple elements can be specified, in the same way as for the \-\-input option +Specify files, directories or patterns to exclude from the backup. Matching files and directories +will not be copied. Multiple elements can be specified, in the same way as for the \-\-input option .TP .B \-k, \-\-keep N -Specify how many old backups (so excluding the current one) will be kept. The default behavior is to keep them all -(same as N=\-1) +Specify how many old backups (so excluding the current one) will be kept. The default behavior +is to keep them all (same as N=\-1) +.TP +.B \-\-host HOSTNAME +Hostname of the server where to copy the files in case of remote backup through ssh +.TP +.B \-u, \-\-username USERNAME +Username for connecting to the server in case of remote backup +.TP +.B \-\-keyfile FILE +Location of the SSH key for server authentication. .TP .B \-s, \-\-checksums -Same as rsync option \(aq\-\-checksum\(aq, use checksums instead of mod\-time and size to skip files. +Same as rsync option \(aq\-\-checksum\(aq, use checksums instead of mod\-time and size +to skip files. +.TP +.B \-z, \-\-compress +Compress data during transfer (rsync \(aq\-\-compress\(aq option). Useful for remote backup +if saving bandwith is needed. .TP .B \-\-remove\-before\-backup -Remove old backups (if necessary) before creating the new backup. Useful to free some space before performing the backup. +Remove old backups (if necessary) before creating the new backup. Useful to free some space +before performing the backup. Default behavior is to remove old backups after successfully completing the backup. .SH CONFIGURATION -An example configuration file is provided at \(aq/etc/simple_backup/simple_backup.conf\(aq. Copy it to the default location -($HOME/.config/simple_backup) and edit it as needed. +An example configuration file is provided at \(aq/etc/simple_backup/simple_backup.conf\(aq. +Copy it to the default location ($HOME/.config/simple_backup) and edit it as needed. +.SH REMOTE BACKUP +It is possible to choose a directory on a remote server as destination for the backup. The files +are copied by rsync through SSH. Server hostname and username must be specified, either in the +configuration file, or on the command line (\(aq\-\-host\(aq and \(aq\-\-username\(aq options). +Currently only authentication with SSH key has been tested. The easiest way to connect to the +server is to use an ssh agent. Otherwise, if the SSH key is encrypted, it will be necessary +to enter the passphrase more than once. It is possible to specify the SSH key to use with the +option \(aq\-\-keyfile\(aq, if necessary. .SH SEE ALSO .BR rsync (1) .SH AUTHORS diff --git a/simple_backup/simple_backup.py b/simple_backup/simple_backup.py index 06e42a5..e63ed72 100755 --- a/simple_backup/simple_backup.py +++ b/simple_backup/simple_backup.py @@ -516,7 +516,7 @@ class Backup: def _parse_arguments(): parser = argparse.ArgumentParser(prog='simple_backup', description='Simple backup script written in Python that uses rsync to copy files', - epilog='Report bugs to dfucinigmailcom', + epilog='See simple_backup(1) manpage for full documentation', formatter_class=MyFormatter) parser.add_argument('-c', '--config', default=f'{homedir}/.config/simple_backup/simple_backup.conf', From 3d3fbbcbd99f72671d1611701b11af13b99be85c Mon Sep 17 00:00:00 2001 From: Fuxino Date: Fri, 2 Jun 2023 00:09:14 +0200 Subject: [PATCH 13/23] Add password authentication for SSH --- README.md | 3 ++- man/simple_backup.1 | 46 ++++++++++++++++++++++++++++------ simple_backup/simple_backup.py | 34 +++++++++++++++++-------- 3 files changed, 64 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 5a1050a..d04d19b 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ It's possible to use a remote server as destination for the backup. Just use the For this to work, rsync must be installed on the server too. ### Server authentication -Right now only authentication using SSH key works. The best way to handle the authentication is to have an ssh agent running on your system, otherwise if a passphrase is necessary to unlock the ssh key, it will be necessary to enter it more than once. +The best way to handle the authentication is to have an ssh agent running on your system, otherwise if a passphrase is necessary to unlock the ssh key, it will be necessary to enter it more than once. If needed, it's possible to specify the ssh key location with the --keyfile argument or in the configuration file. To be able to connect to the user's ssh agent when running simple_backup with sudo, make sure to preserve the SSH_AUTH_SOCK environment variable. For example: @@ -65,3 +65,4 @@ sudo --preserve-env=SSH_AUTH_SOCK -s simple_backup [options] ``` or by editing the sudoers file. +If SSH key authentication is not available, password authentication will be used instead. diff --git a/man/simple_backup.1 b/man/simple_backup.1 index 98b7c61..79b09fc 100644 --- a/man/simple_backup.1 +++ b/man/simple_backup.1 @@ -28,11 +28,13 @@ simple_backup \- Backup files and folders using rsync .RE .SH DESCRIPTION .BR simple_backup -is a python script for performing backup of files and folders. It uses rsync to copy the files -to the specified location. Parameters for the backup such as input files/directories, output -location and files or folders to exclude can be specified +is a python script for performing backup of files and folders. +.P +It uses rsync to copy the files to the specified location. Parameters for the backup such as +input files/directories, output location and files or folders to exclude can be specified in a configuration file (default location $HOME/.config/simple_backup/simple_backup.conf) or directly on the command line. +.P Parameters specified on the command line will override those in the configuration file. .SH OPTIONS .TP @@ -61,7 +63,7 @@ Specify how many old backups (so excluding the current one) will be kept. The de is to keep them all (same as N=\-1) .TP .B \-\-host HOSTNAME -Hostname of the server where to copy the files in case of remote backup through ssh +Hostname of the server where to copy the files in case of remote backup through SSH .TP .B \-u, \-\-username USERNAME Username for connecting to the server in case of remote backup @@ -88,10 +90,38 @@ Copy it to the default location ($HOME/.config/simple_backup) and edit it as nee It is possible to choose a directory on a remote server as destination for the backup. The files are copied by rsync through SSH. Server hostname and username must be specified, either in the configuration file, or on the command line (\(aq\-\-host\(aq and \(aq\-\-username\(aq options). -Currently only authentication with SSH key has been tested. The easiest way to connect to the -server is to use an ssh agent. Otherwise, if the SSH key is encrypted, it will be necessary -to enter the passphrase more than once. It is possible to specify the SSH key to use with the -option \(aq\-\-keyfile\(aq, if necessary. +.SS AUTHENTICATION +For authentication, it is possible to use SSH key or password. +.P +When using SSH key, the best way to connect to the server is to have an SSH agent running. +Otherwise, if the SSH key is encrypted, it will be necessary to enter the passphrase more +than once. It is possible to specify the SSH key to use with the option \(aq\-\-keyfile\(aq, +if necessary. +.P +When running +.B simple_backup +with +.B sudo, +in order to connect to the user\(aq s SSH agent it is necessary to preserve the \(aq SSH_AUTH_SOCK\(aq environment variable, for example: +.P +.EX + sudo --preserve-env=SSH_AUTH_SOCK -s simple_backup [options] +.EE +.P +It is also possible to make this permanent by editing the +.B sudoers +file (see +.B sudoers (5) +) +.P +If SSH key authentication is not available, password authentication will be used instead. +Note that in this case +.B sshpass +(if available) will be used to send the password to rsync, to avoid prompting the user for +the password multiple +times. This can pose some security risks, see +.B sshpass (1) +for details. For this reason, use SSH key authentication if possible. .SH SEE ALSO .BR rsync (1) .SH AUTHORS diff --git a/simple_backup/simple_backup.py b/simple_backup/simple_backup.py index e63ed72..31c674c 100755 --- a/simple_backup/simple_backup.py +++ b/simple_backup/simple_backup.py @@ -10,13 +10,12 @@ Classes: MyFormatter Backup """ - # Import libraries import sys import os import warnings from functools import wraps -from shutil import rmtree +from shutil import rmtree, which import shlex import argparse import configparser @@ -155,6 +154,8 @@ class Backup: self._remote = None self._err_flag = False self._ssh = None + self._password_auth = False + self._password = None def check_params(self): """Check if parameters for the backup are valid""" @@ -325,13 +326,22 @@ class Backup: except paramiko.SSHException: pass - pkey = None - password = None - if self.ssh_keyfile is None: - logger.critical('Can\'t connect to the server. No authentication method available') + try: + password = getpass(f'{self.username}@{self.host}\'s password: ') + ssh.connect(self.host, username=self.username, password=password) - return None + self._password_auth = True + os.environ['SSHPASS'] = password + + return ssh + except paramiko.SSHException as e: + logger.critical('Can\'t connect to the server.') + logger.critical(e) + + return None + + pkey = None try: pkey = RSAKey.from_private_key_file(self.ssh_keyfile) @@ -427,12 +437,16 @@ class Backup: if euid == 0 and self.ssh_keyfile is not None: rsync = f'{rsync} -e \'ssh -i {self.ssh_keyfile}\'' + elif self._password_auth and which('sshpass'): + rsync = f'{rsync} -e \'sshpass -e ssh -l {self.username}\'' args = shlex.split(rsync) with Popen(args, stdin=PIPE, stdout=PIPE, stderr=STDOUT, shell=False) as p: output, _ = p.communicate() + del os.environ['SSHPASS'] + if p.returncode != 0: self._err_flag = True @@ -444,7 +458,7 @@ class Backup: logger.info('rsync: %s', output[-3]) logger.info('rsync: %s', output[-2]) - if self._remote: + if self._remote and not self._err_flag: _, stdout, _ = \ self._ssh.exec_command(f'if [ -L "{self.output}/simple_backup/last_backup" ]; then echo "ok"; fi') @@ -458,7 +472,7 @@ class Backup: if err != '': logger.error(err) self._err_flag = True - else: + elif not self._err_flag: if os.path.islink(f'{self.output}/simple_backup/last_backup'): try: os.remove(f'{self.output}/simple_backup/last_backup') @@ -478,7 +492,7 @@ class Backup: if err != '': logger.error(err) self._err_flag = True - elif not self._remote: + elif not self._err_flag: try: os.symlink(self._output_dir, f'{self.output}/simple_backup/last_backup', target_is_directory=True) except FileExistsError: From b34627fe587824b2cf5be8aa0b7d5fe9c6e83a2d Mon Sep 17 00:00:00 2001 From: Fuxino Date: Sat, 3 Jun 2023 15:44:31 +0200 Subject: [PATCH 14/23] Improve readability --- simple_backup/simple_backup.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/simple_backup/simple_backup.py b/simple_backup/simple_backup.py index dabb6e6..f0bfaa5 100755 --- a/simple_backup/simple_backup.py +++ b/simple_backup/simple_backup.py @@ -420,12 +420,10 @@ class Backup: if self._last_backup == '': rsync = f'/usr/bin/rsync {self.options} --exclude-from={self._exclude_path} ' +\ - f'--files-from={self._inputs_path} / "{self._server}{self._output_dir}" ' +\ - '--ignore-missing-args --mkpath --protect-args' + f'--files-from={self._inputs_path} / "{self._server}{self._output_dir}"' else: rsync = f'/usr/bin/rsync {self.options} --link-dest="{self._last_backup}" --exclude-from=' +\ - f'{self._exclude_path} --files-from={self._inputs_path} / "{self._server}{self._output_dir}" ' +\ - '--ignore-missing-args --mkpath --protect-args' + f'{self._exclude_path} --files-from={self._inputs_path} / "{self._server}{self._output_dir}"' if euid == 0 and self.ssh_keyfile is not None: rsync = f'{rsync} -e \'ssh -i {self.ssh_keyfile}\'' @@ -520,10 +518,13 @@ def _read_config(config_file): try: host = config.get('server', 'host') username = config.get('server', 'username') - ssh_keyfile = config.get('server', 'ssh_keyfile') except (configparser.NoSectionError, configparser.NoOptionError): host = None username = None + + try: + ssh_keyfile = config.get('server', 'ssh_keyfile') + except (configparser.NoSectionError, configparser.NoOptionError): ssh_keyfile = None return inputs, output, exclude, keep, host, username, ssh_keyfile @@ -551,7 +552,7 @@ def simple_backup(): """Main""" args = _parse_arguments() - inputs, output, exclude, keep, username, host, ssh_keyfile = _read_config(args.config) + inputs, output, exclude, keep, host, username, ssh_keyfile = _read_config(args.config) if args.input is not None: inputs = args.input @@ -574,7 +575,7 @@ def simple_backup(): if args.keyfile is not None: ssh_keyfile = args.keyfile - backup_options = ['-a', '-r', '-v', '-h', '-H', '-X'] + backup_options = ['-a', '-r', '-v', '-h', '-H', '-X', '-s', '--ignore-missing-args', '--mkpath'] if args.checksum: backup_options.append('-c') From 809545b172d30e8f691ddb1f0e23113e8dfad0c7 Mon Sep 17 00:00:00 2001 From: Fuxino Date: Sat, 3 Jun 2023 15:56:24 +0200 Subject: [PATCH 15/23] Explicitly close paramiko connection --- simple_backup/simple_backup.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/simple_backup/simple_backup.py b/simple_backup/simple_backup.py index f0bfaa5..bb1a5cc 100755 --- a/simple_backup/simple_backup.py +++ b/simple_backup/simple_backup.py @@ -174,7 +174,7 @@ class Backup: self._remote = True if self._remote: - self._ssh = self._ssh_connection() + self._ssh = self._ssh_connect() if self._ssh is None: sys.exit(1) @@ -264,6 +264,8 @@ class Backup: elif count > 1: logger.info('Removed %d backups', count) + self._ssh.close() + def find_last_backup(self): """Get path of last backup (from last_backup symlink) for rsync --link-dest""" @@ -292,7 +294,7 @@ class Backup: except IndexError: logger.info('No previous backups available') - def _ssh_connection(self): + def _ssh_connect(self): ssh = paramiko.SSHClient() ssh.load_system_host_keys() ssh.set_missing_host_key_policy(paramiko.WarningPolicy()) From 252617e4f2c976f3d32fcc475678842c56737190 Mon Sep 17 00:00:00 2001 From: Fuxino Date: Sat, 3 Jun 2023 16:09:34 +0200 Subject: [PATCH 16/23] Fix old backups counting --- simple_backup/simple_backup.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/simple_backup/simple_backup.py b/simple_backup/simple_backup.py index bb1a5cc..ce3c5cf 100755 --- a/simple_backup/simple_backup.py +++ b/simple_backup/simple_backup.py @@ -145,7 +145,7 @@ class Backup: self.host = host self.username = username self.ssh_keyfile = ssh_keyfile - self.remove_before = remove_before + self._remove_before = remove_before self._last_backup = '' self._server = '' self._output_dir = '' @@ -217,7 +217,11 @@ class Backup: dirs = stdout.read().decode('utf-8').strip().split('\n') - n_backup = len(dirs) - 1 + n_backup = len(dirs) + + if not self._remove_before: + n_backup -= 1 + count = 0 if n_backup > self.keep: @@ -240,10 +244,11 @@ class Backup: except FileNotFoundError: return - if dirs.count('last_backup') > 0: - dirs.remove('last_backup') + n_backup = len(dirs) + + if not self._remove_before: + n_backup -= 1 - n_backup = len(dirs) - 1 count = 0 if n_backup > self.keep: @@ -415,7 +420,7 @@ class Backup: fp.write(e) fp.write('\n') - if self.keep != -1 and self.remove_before: + if self.keep != -1 and self._remove_before: self.remove_old_backups() logger.info('Copying files. This may take a long time...') @@ -453,7 +458,7 @@ class Backup: logger.info('rsync: %s', output[-3]) logger.info('rsync: %s', output[-2]) - if self.keep != -1 and not self.remove_before: + if self.keep != -1 and not self._remove_before: self.remove_old_backups() os.remove(self._inputs_path) From cee4d138370e6000c2fd3608c3525b55f1443f8e Mon Sep 17 00:00:00 2001 From: Fuxino Date: Sat, 3 Jun 2023 16:14:36 +0200 Subject: [PATCH 17/23] Fix bug Fix crash when attempting to close a non-existent paramiko SSH connection --- simple_backup/simple_backup.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/simple_backup/simple_backup.py b/simple_backup/simple_backup.py index ce3c5cf..4176423 100755 --- a/simple_backup/simple_backup.py +++ b/simple_backup/simple_backup.py @@ -269,7 +269,8 @@ class Backup: elif count > 1: logger.info('Removed %d backups', count) - self._ssh.close() + if self._ssh: + self._ssh.close() def find_last_backup(self): """Get path of last backup (from last_backup symlink) for rsync --link-dest""" From f37cd89b4ed43912703557b1e1ce0e68181722f3 Mon Sep 17 00:00:00 2001 From: Fuxino Date: Thu, 15 Jun 2023 09:30:59 +0200 Subject: [PATCH 18/23] Fix parsing of old config file --- simple_backup/simple_backup.py | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/simple_backup/simple_backup.py b/simple_backup/simple_backup.py index e564d84..ba2ac91 100755 --- a/simple_backup/simple_backup.py +++ b/simple_backup/simple_backup.py @@ -533,15 +533,23 @@ def _read_config(config_file): config = configparser.ConfigParser() config.read(config_file) - inputs = config.get('backup', 'inputs') + section = 'backup' + + # Allow compatibility with previous version of config file + try: + inputs = config.get(section, 'inputs') + except configparser.NoSectionError: + section = 'default' + inputs = config.get(section, 'inputs') + inputs = inputs.split(',') inputs = _expand_inputs(inputs) inputs = list(set(inputs)) - output = config.get('backup', 'backup_dir') + output = config.get(section, 'backup_dir') output = os.path.expanduser(output.replace('~', f'~{user}')) - exclude = config.get('backup', 'exclude') + exclude = config.get(section, 'exclude') exclude = exclude.split(',') - keep = config.getint('backup', 'keep') + keep = config.getint(section, 'keep') try: host = config.get('server', 'host') @@ -587,7 +595,11 @@ def simple_backup(): except NameError: pass - inputs, output, exclude, keep, host, username, ssh_keyfile = _read_config(args.config) + try: + inputs, output, exclude, keep, host, username, ssh_keyfile = _read_config(args.config) + except (configparser.NoSectionError, configparser.NoOptionError): + logger.critical('Bad configuration file') + sys.exit(1) if args.input is not None: inputs = args.input From f95cd86cdccd8f7bf37ff7ea1f9d9f63ef984caf Mon Sep 17 00:00:00 2001 From: Fuxino Date: Thu, 15 Jun 2023 11:48:23 +0200 Subject: [PATCH 19/23] Check backup folder for remote backup Check that the backup folder exists at the end of the backup when performing backup over ssh --- simple_backup/simple_backup.py | 66 +++++++++++++++++++++++++--------- 1 file changed, 49 insertions(+), 17 deletions(-) diff --git a/simple_backup/simple_backup.py b/simple_backup/simple_backup.py index ba2ac91..3ca6605 100755 --- a/simple_backup/simple_backup.py +++ b/simple_backup/simple_backup.py @@ -271,9 +271,6 @@ class Backup: elif count > 1: logger.info('Removed %d backups', count) - if self._ssh: - self._ssh.close() - def find_last_backup(self): """Get path of last backup (from last_backup symlink) for rsync --link-dest""" @@ -304,7 +301,12 @@ class Backup: def _ssh_connect(self): ssh = paramiko.SSHClient() - ssh.load_system_host_keys() + + try: + ssh.load_host_keys(filename=f'{homedir}/.ssh/known_hosts') + except FileNotFoundError: + logger.warning(f'Cannot find file {homedir}/.ssh/known_hosts') + ssh.set_missing_host_key_policy(paramiko.WarningPolicy()) try: @@ -318,6 +320,11 @@ class Backup: ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) else: return None + except paramiko.BadHostKeyException as e: + logger.critical('Can\'t connect to the server.') + logger.critical(e) + + return None except paramiko.SSHException: pass @@ -436,9 +443,11 @@ class Backup: f'{self._exclude_path} --files-from={self._inputs_path} / "{self._server}{self._output_dir}"' if euid == 0 and self.ssh_keyfile is not None: - rsync = f'{rsync} -e \'ssh -i {self.ssh_keyfile}\'' + rsync = f'{rsync} -e \'ssh -i {self.ssh_keyfile} -o StrictHostKeyChecking=no\'' elif self._password_auth and which('sshpass'): - rsync = f'{rsync} -e \'sshpass -e ssh -l {self.username}\'' + rsync = f'{rsync} -e \'sshpass -e ssh -l {self.username} -o StrictHostKeyChecking=no\'' + else: + rsync = f'{rsync} -e \'ssh -o StrictHostKeyChecking=no\'' args = shlex.split(rsync) @@ -467,20 +476,43 @@ class Backup: os.remove(self._inputs_path) os.remove(self._exclude_path) - logger.info('Backup completed') + if self._remote: + _, stdout, _ = self._ssh.exec_command(f'if [ -d "{self._output_dir}" ]; then echo "ok"; fi') - if self._err_flag: - logger.warning('Some errors occurred') + output = stdout.read().decode('utf-8').strip() - try: - _notify('Backup finished with errors (check log for details)') - except NameError: - pass + if output == 'ok': + logger.info('Backup completed') + + try: + _notify('Backup completed') + except NameError: + pass + else: + logger.error('Backup failed') + + try: + _notify('Backup failed (check log for details)') + except NameError: + pass + + if self._ssh: + self._ssh.close() else: - try: - _notify('Backup finished') - except NameError: - pass + if self._err_flag: + logger.error('Some errors occurred while performing the backup') + + try: + _notify('Some errors occurred while performing the backup. Check log for details') + except NameError: + pass + else: + logger.info('Backup completed') + + try: + _notify('Backup completed') + except NameError: + pass def _parse_arguments(): From f77ff2d24f8571a719a062d951ec54efa4db446b Mon Sep 17 00:00:00 2001 From: Fuxino Date: Thu, 15 Jun 2023 23:12:19 +0200 Subject: [PATCH 20/23] Allow running remote rsync as sudo --- man/simple_backup.1 | 15 +++++++++--- simple_backup/simple_backup.conf | 1 + simple_backup/simple_backup.py | 39 ++++++++++++++++++++++++-------- 3 files changed, 43 insertions(+), 12 deletions(-) diff --git a/man/simple_backup.1 b/man/simple_backup.1 index 23b3364..296e610 100644 --- a/man/simple_backup.1 +++ b/man/simple_backup.1 @@ -91,11 +91,11 @@ Don't use systemd journal for logging By default, the following rsync options are used: .RS .PP -\-a \-r \-c \-v \-h \-H \-X + \-a \-r \-v \-h \-s \-H \-X .PP Using \-\-rsync\-options it is possible to manually select which options to use. Supported values are the following: .PP -\-a, \-l, \-p, \-t, \-g, \-o, \-c, \-h, \-D, \-H, \-X + \-a, \-l, \-p, \-t, \-g, \-o, \-c, \-h, \-D, \-H, \-X, \-s .PP Options \-r and \-v are used in any case. Not that options must be specified without dash (\-), for example: .PP @@ -103,7 +103,16 @@ Options \-r and \-v are used in any case. Not that options must be specified wit simple_backup \-\-rsync\-options a l p .EE .TP -Check rsync(1) for details about the options. +Check +.B rsync (1) +for details about the options. +.RE +.TP +.B \-\-remote\-sudo +Run rsync on the remote server with sudo. For this to work the user used to login to the server obviously need to be allowed to use sudo. In addition, the user need to be able to run rsync with sudo without a password. To do this, /etc/sudoers on the server need to be edited adding a line like this one: +.RS +.PP + ALL=NOPASSWD: .RE .SH CONFIGURATION An example configuration file is provided at \(aq/usr/share/doc/simple_backup/simple_backup.conf\(aq. diff --git a/simple_backup/simple_backup.conf b/simple_backup/simple_backup.conf index 4806436..4748976 100644 --- a/simple_backup/simple_backup.conf +++ b/simple_backup/simple_backup.conf @@ -18,3 +18,4 @@ keep=-1 # host= # username= # ssh_keyfile= +# remote_sudo= diff --git a/simple_backup/simple_backup.py b/simple_backup/simple_backup.py index 5e72e53..4131fd6 100755 --- a/simple_backup/simple_backup.py +++ b/simple_backup/simple_backup.py @@ -137,7 +137,7 @@ class Backup: """ def __init__(self, inputs, output, exclude, keep, options, host=None, - username=None, ssh_keyfile=None, remove_before=False): + username=None, ssh_keyfile=None, remote_sudo=False, remove_before=False): self.inputs = inputs self.output = output self.exclude = exclude @@ -146,6 +146,7 @@ class Backup: self.host = host self.username = username self.ssh_keyfile = ssh_keyfile + self.remote_sudo = remote_sudo self._remove_before = remove_before self._last_backup = '' self._server = '' @@ -470,6 +471,9 @@ class Backup: else: rsync = f'{rsync} -e \'ssh -o StrictHostKeyChecking=no\'' + if self._remote and self.remote_sudo: + rsync = f'{rsync} --rsync-path="sudo rsync"' + args = shlex.split(rsync) with Popen(args, stdin=PIPE, stdout=PIPE, stderr=STDOUT, shell=False) as p: @@ -562,8 +566,9 @@ def _parse_arguments(): help='Remove old backups before executing the backup, instead of after') parser.add_argument('--no-syslog', action='store_true', help='Disable systemd journal logging') parser.add_argument('--rsync-options', nargs='+', - choices=['a', 'l', 'p', 't', 'g', 'o', 'c', 'h', 'D', 'H', 'X'], + choices=['a', 'l', 'p', 't', 'g', 'o', 'c', 'h', 's', 'D', 'H', 'X'], help='Specify options for rsync') + parser.add_argument('--remote-sudo', action='store_true', help='Run rsync on remote server with sudo if allowed') args = parser.parse_args() @@ -591,7 +596,7 @@ def _read_config(config_file): if not os.path.isfile(config_file): logger.warning('Config file %s does not exist', config_file) - return None, None, None, None, None, None, None + return None, None, None, None, None, None, None, None config = configparser.ConfigParser() config.read(config_file) @@ -610,9 +615,17 @@ def _read_config(config_file): inputs = list(set(inputs)) output = config.get(section, 'backup_dir') output = os.path.expanduser(output.replace('~', f'~{user}')) - exclude = config.get(section, 'exclude') - exclude = exclude.split(',') - keep = config.getint(section, 'keep') + + try: + exclude = config.get(section, 'exclude') + exclude = exclude.split(',') + except configparser.NoOptionError: + exclude = [] + + try: + keep = config.getint(section, 'keep') + except configparser.NoOptionError: + keep = -1 try: host = config.get('server', 'host') @@ -626,7 +639,12 @@ def _read_config(config_file): except (configparser.NoSectionError, configparser.NoOptionError): ssh_keyfile = None - return inputs, output, exclude, keep, host, username, ssh_keyfile + try: + remote_sudo = config.getboolean('server', 'remote_sudo') + except (configparser.NoSectionError, configparser.NoOptionError): + remote_sudo = False + + return inputs, output, exclude, keep, host, username, ssh_keyfile, remote_sudo def _notify(text): @@ -659,7 +677,7 @@ def simple_backup(): pass try: - inputs, output, exclude, keep, host, username, ssh_keyfile = _read_config(args.config) + inputs, output, exclude, keep, host, username, ssh_keyfile, remote_sudo = _read_config(args.config) except (configparser.NoSectionError, configparser.NoOptionError): logger.critical('Bad configuration file') sys.exit(1) @@ -699,10 +717,13 @@ def simple_backup(): if args.compress: rsync_options.append('-z') + if args.remote_sudo is not None: + remote_sudo = args.remote_sudo + rsync_options = ' '.join(rsync_options) backup = Backup(inputs, output, exclude, keep, rsync_options, host, username, - ssh_keyfile, remove_before=args.remove_before_backup) + ssh_keyfile, remote_sudo, remove_before=args.remove_before_backup) return_code = backup.check_params() From 169f824d831022cfba93d5d43eb186bb1de15c53 Mon Sep 17 00:00:00 2001 From: Fuxino Date: Thu, 15 Jun 2023 23:15:00 +0200 Subject: [PATCH 21/23] Improve documentation --- man/simple_backup.1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/man/simple_backup.1 b/man/simple_backup.1 index 296e610..3b7c5f6 100644 --- a/man/simple_backup.1 +++ b/man/simple_backup.1 @@ -109,7 +109,7 @@ for details about the options. .RE .TP .B \-\-remote\-sudo -Run rsync on the remote server with sudo. For this to work the user used to login to the server obviously need to be allowed to use sudo. In addition, the user need to be able to run rsync with sudo without a password. To do this, /etc/sudoers on the server need to be edited adding a line like this one: +Run rsync on the remote server with sudo. This is needed if you want to preserve the owner of the files/folders to be copied (rsync \-\-owner option). For this to work the user used to login to the server obviously need to be allowed to use sudo. In addition, the user need to be able to run rsync with sudo without a password. To do this, /etc/sudoers on the server need to be edited adding a line like this one: .RS .PP ALL=NOPASSWD: From ffed2dec9016c03cab7c50260c10a22e1b62d40e Mon Sep 17 00:00:00 2001 From: Fuxino Date: Fri, 16 Jun 2023 16:18:12 +0200 Subject: [PATCH 22/23] Add numeric-ids option for rsync --- man/simple_backup.1 | 3 +++ simple_backup/simple_backup.conf | 1 + simple_backup/simple_backup.py | 15 ++++++++++++--- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/man/simple_backup.1 b/man/simple_backup.1 index 3b7c5f6..e316258 100644 --- a/man/simple_backup.1 +++ b/man/simple_backup.1 @@ -114,6 +114,9 @@ Run rsync on the remote server with sudo. This is needed if you want to preserve .PP ALL=NOPASSWD: .RE +.TP +.B \-\-numeric\-ids +Use rsync \-\-numeric\-ids option. This causes rsync to use numeric uid/gid instead of trying to map uid/gid names from the local machine to the server .SH CONFIGURATION An example configuration file is provided at \(aq/usr/share/doc/simple_backup/simple_backup.conf\(aq. Copy it to the default location ($HOME/.config/simple_backup) and edit it as needed. diff --git a/simple_backup/simple_backup.conf b/simple_backup/simple_backup.conf index 4748976..be2a1f6 100644 --- a/simple_backup/simple_backup.conf +++ b/simple_backup/simple_backup.conf @@ -19,3 +19,4 @@ keep=-1 # username= # ssh_keyfile= # remote_sudo= +# numeric_ids= diff --git a/simple_backup/simple_backup.py b/simple_backup/simple_backup.py index 4131fd6..2db1059 100755 --- a/simple_backup/simple_backup.py +++ b/simple_backup/simple_backup.py @@ -569,6 +569,7 @@ def _parse_arguments(): choices=['a', 'l', 'p', 't', 'g', 'o', 'c', 'h', 's', 'D', 'H', 'X'], help='Specify options for rsync') parser.add_argument('--remote-sudo', action='store_true', help='Run rsync on remote server with sudo if allowed') + parser.add_argument('--numeric-ids', action='store_true', help='Use rsync \'--numeric-ids\' option (don\'t map uid/gid values by name') args = parser.parse_args() @@ -596,7 +597,7 @@ def _read_config(config_file): if not os.path.isfile(config_file): logger.warning('Config file %s does not exist', config_file) - return None, None, None, None, None, None, None, None + return None, None, None, None, None, None, None, None, None config = configparser.ConfigParser() config.read(config_file) @@ -644,7 +645,12 @@ def _read_config(config_file): except (configparser.NoSectionError, configparser.NoOptionError): remote_sudo = False - return inputs, output, exclude, keep, host, username, ssh_keyfile, remote_sudo + try: + numeric_ids = config.getboolean('server', 'numeric_ids') + except (configparser.NoSectionError, configparser.NoOptionError): + numeric_ids = False + + return inputs, output, exclude, keep, host, username, ssh_keyfile, remote_sudo, numeric_ids def _notify(text): @@ -677,7 +683,7 @@ def simple_backup(): pass try: - inputs, output, exclude, keep, host, username, ssh_keyfile, remote_sudo = _read_config(args.config) + inputs, output, exclude, keep, host, username, ssh_keyfile, remote_sudo, numeric_ids = _read_config(args.config) except (configparser.NoSectionError, configparser.NoOptionError): logger.critical('Bad configuration file') sys.exit(1) @@ -717,6 +723,9 @@ def simple_backup(): if args.compress: rsync_options.append('-z') + if numeric_ids or args.numeric_ids: + rsync_options.append('--numeric-ids') + if args.remote_sudo is not None: remote_sudo = args.remote_sudo From 4e6adf3c56abf3fa62f12367f56aeb1738112866 Mon Sep 17 00:00:00 2001 From: Fuxino Date: Fri, 16 Jun 2023 17:42:13 +0200 Subject: [PATCH 23/23] Use dict for config options --- simple_backup/simple_backup.py | 74 +++++++++++++++++++--------------- 1 file changed, 41 insertions(+), 33 deletions(-) diff --git a/simple_backup/simple_backup.py b/simple_backup/simple_backup.py index 2db1059..69d15b4 100755 --- a/simple_backup/simple_backup.py +++ b/simple_backup/simple_backup.py @@ -120,6 +120,8 @@ class Backup: Username for server login (for remote backup) ssh_keyfile: str Location of ssh key + remote_sudo: bool + Run remote rsync with sudo remove_before: bool Indicate if removing old backups will be performed before copying files @@ -136,8 +138,8 @@ class Backup: Perform the backup """ - def __init__(self, inputs, output, exclude, keep, options, host=None, - username=None, ssh_keyfile=None, remote_sudo=False, remove_before=False): + def __init__(self, inputs, output, exclude, keep, options, host=None, username=None, + ssh_keyfile=None, remote_sudo=False, remove_before=False): self.inputs = inputs self.output = output self.exclude = exclude @@ -552,7 +554,7 @@ def _parse_arguments(): parser.add_argument('-c', '--config', default=f'{homedir}/.config/simple_backup/simple_backup.conf', help='Specify location of configuration file') - parser.add_argument('-i', '--input', nargs='+', help='Paths/files to backup') + parser.add_argument('-i', '--inputs', nargs='+', help='Paths/files to backup') parser.add_argument('-o', '--output', help='Output directory for the backup') parser.add_argument('-e', '--exclude', nargs='+', help='Files/directories/patterns to exclude from the backup') parser.add_argument('-k', '--keep', type=int, help='Number of old backups to keep') @@ -569,7 +571,8 @@ def _parse_arguments(): choices=['a', 'l', 'p', 't', 'g', 'o', 'c', 'h', 's', 'D', 'H', 'X'], help='Specify options for rsync') parser.add_argument('--remote-sudo', action='store_true', help='Run rsync on remote server with sudo if allowed') - parser.add_argument('--numeric-ids', action='store_true', help='Use rsync \'--numeric-ids\' option (don\'t map uid/gid values by name') + parser.add_argument('--numeric-ids', action='store_true', + help='Use rsync \'--numeric-ids\' option (don\'t map uid/gid values by name') args = parser.parse_args() @@ -594,10 +597,12 @@ def _expand_inputs(inputs): def _read_config(config_file): + config_args = {} + if not os.path.isfile(config_file): logger.warning('Config file %s does not exist', config_file) - return None, None, None, None, None, None, None, None, None + return config_args config = configparser.ConfigParser() config.read(config_file) @@ -614,20 +619,29 @@ def _read_config(config_file): inputs = inputs.split(',') inputs = _expand_inputs(inputs) inputs = list(set(inputs)) + + config_args['inputs'] = inputs + output = config.get(section, 'backup_dir') output = os.path.expanduser(output.replace('~', f'~{user}')) + config_args['output'] = output + try: exclude = config.get(section, 'exclude') exclude = exclude.split(',') except configparser.NoOptionError: exclude = [] + config_args['exclude'] = exclude + try: keep = config.getint(section, 'keep') except configparser.NoOptionError: keep = -1 + config_args['keep'] = keep + try: host = config.get('server', 'host') username = config.get('server', 'username') @@ -635,22 +649,31 @@ def _read_config(config_file): host = None username = None + config_args['host'] = host + config_args['username'] = username + try: ssh_keyfile = config.get('server', 'ssh_keyfile') except (configparser.NoSectionError, configparser.NoOptionError): ssh_keyfile = None + config_args['ssh_keyfile'] = ssh_keyfile + try: remote_sudo = config.getboolean('server', 'remote_sudo') except (configparser.NoSectionError, configparser.NoOptionError): remote_sudo = False + config_args['remote_sudo'] = remote_sudo + try: numeric_ids = config.getboolean('server', 'numeric_ids') except (configparser.NoSectionError, configparser.NoOptionError): numeric_ids = False - return inputs, output, exclude, keep, host, username, ssh_keyfile, remote_sudo, numeric_ids + config_args['numeric_ids'] = numeric_ids + + return config_args def _notify(text): @@ -683,31 +706,19 @@ def simple_backup(): pass try: - inputs, output, exclude, keep, host, username, ssh_keyfile, remote_sudo, numeric_ids = _read_config(args.config) + config_args = _read_config(args.config) except (configparser.NoSectionError, configparser.NoOptionError): logger.critical('Bad configuration file') sys.exit(1) - if args.input is not None: - inputs = args.input - - if args.output is not None: - output = args.output - - if args.exclude is not None: - exclude = args.exclude - - if args.keep is not None: - keep = args.keep - - if args.host is not None: - host = args.host - - if args.username is not None: - username = args.username - - if args.keyfile is not None: - ssh_keyfile = args.keyfile + inputs = args.inputs if args.inputs is not None else config_args['inputs'] + output = args.output if args.output is not None else config_args['output'] + exclude = args.exclude if args.exclude is not None else config_args['exclude'] + keep = args.keep if args.keep is not None else config_args['keep'] + host = args.host if args.host is not None else config_args['host'] + username = args.username if args.username is not None else config_args['username'] + ssh_keyfile = args.keyfile if args.keyfile is not None else config_args['ssh_keyfile'] + remote_sudo = args.remote_sudo if args.remote_sudo is not None else config_args['remote_sudo'] if args.rsync_options is None: rsync_options = ['-a', '-r', '-v', '-h', '-H', '-X', '-s', '--ignore-missing-args', '--mkpath'] @@ -723,16 +734,13 @@ def simple_backup(): if args.compress: rsync_options.append('-z') - if numeric_ids or args.numeric_ids: + if args.numeric_ids or config_args['numeric_ids']: rsync_options.append('--numeric-ids') - if args.remote_sudo is not None: - remote_sudo = args.remote_sudo - rsync_options = ' '.join(rsync_options) - backup = Backup(inputs, output, exclude, keep, rsync_options, host, username, - ssh_keyfile, remote_sudo, remove_before=args.remove_before_backup) + backup = Backup(inputs, output, exclude, keep, rsync_options, host, username, ssh_keyfile, + remote_sudo, remove_before=args.remove_before_backup) return_code = backup.check_params()