Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
166c9dbc47
|
||
|
|
28d1b0d0d0
|
||
|
|
cd80c941ad
|
||
|
|
29958896a2
|
||
|
|
d815b0832a
|
||
|
|
e6ac9e6cab
|
||
|
|
9298f4232c
|
||
|
|
1dca989310
|
||
|
|
9a68a8660d
|
||
|
|
73de4f8d25
|
||
|
|
baf5b07d23
|
||
|
|
8b30fefed3
|
||
|
|
2a5562d1a9
|
||
|
|
7091118663
|
||
|
|
3113e182c7
|
||
|
|
8fc3c42a5e
|
||
|
|
0671f86579
|
||
|
|
2f207efcf9
|
||
|
|
3cbca8e4d8
|
||
|
|
54880651be
|
||
|
|
793a30fad8
|
||
|
|
871dd0f49a
|
||
|
|
30e4416f8f
|
||
|
|
d0f888cbb4
|
||
|
|
7ef29cf551
|
||
|
|
4f5499eba9
|
||
|
|
fc165fccbf
|
||
|
|
d79ea56c0f
|
||
|
|
5189910131
|
||
|
|
7d4c43f8ff
|
||
|
|
ac7e6ec21b
|
||
|
|
578d992a19
|
||
|
|
e2d8897a45
|
||
|
|
dadc4b0265
|
||
|
|
7e7d11c520
|
||
|
|
22e0556b50
|
||
|
|
2b91178e94
|
||
|
|
2c2649113d
|
||
|
|
0f4a100ac7
|
||
|
|
60d83151dc
|
@@ -4,7 +4,7 @@
|
||||
|
||||
pkgname=simple_backup
|
||||
pkgdesc='Simple backup script that uses rsync to copy files'
|
||||
pkgver=4.1.6
|
||||
pkgver=4.2.0
|
||||
pkgrel=1
|
||||
url="https://git.shouldnt.work/fuxino/${pkgname}"
|
||||
arch=('any')
|
||||
@@ -12,18 +12,16 @@ license=('GPL-3.0-or-later')
|
||||
makedepends=('git'
|
||||
'python-setuptools'
|
||||
'python-build'
|
||||
'python-installer'
|
||||
'python-wheel')
|
||||
'python-installer')
|
||||
depends=('python>=3.10'
|
||||
'rsync'
|
||||
'python-dotenv')
|
||||
'rsync')
|
||||
optdepends=('python-systemd: use systemd log'
|
||||
'python-dbus: for desktop notifications'
|
||||
'python-paramiko: for remote backup through ssh')
|
||||
conflicts=('simple_backup-git')
|
||||
source=(git+${url}?signed#tag=${pkgver})
|
||||
validpgpkeys=('7E12BC1FF3B6EDB2CD8053EB981A2B2A3BBF5514')
|
||||
sha256sums=('b3b29d9e2e1b7b949e95674d9a401e8eeb0d5f898e8450473dce94f799ee9df3')
|
||||
sha256sums=('6c674f7981e3e5222594bdb735d4fb02ca2fcc829de57ea8f92ce71e679853f9')
|
||||
|
||||
build()
|
||||
{
|
||||
|
||||
@@ -20,21 +20,19 @@ The script uses rsync to actually run the backup, so you will have to install it
|
||||
sudo pacman -Syu rsync
|
||||
```
|
||||
|
||||
It's also required to have python-dotenv
|
||||
|
||||
Optional dependencies are systemd-python to enable using systemd journal for logging, and dbus-python for desktop notifications.
|
||||
Optional dependencies are systemd-python to enable using systemd journal for logging, dbus-python for desktop notifications, and paramiko for remote backup.
|
||||
|
||||
## Install
|
||||
To install the program, first clone the repository:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Fuxino/simple_backup.git
|
||||
git clone https://git.shouldnt.work/fuxino/simple_backup.git
|
||||
```
|
||||
|
||||
Then install the tools required to build the package:
|
||||
|
||||
```bash
|
||||
pip install --upgrade build wheel
|
||||
pip install --upgrade build
|
||||
```
|
||||
|
||||
Finally, run:
|
||||
@@ -49,11 +47,16 @@ For Arch Linux and Arch-based distros, two packages are available in the AUR (au
|
||||
- **simple_backup** for the release version
|
||||
- **simple_backup-git** for the git version
|
||||
|
||||
## Old backups
|
||||
The number of old backups to keep can be set with the --keep (or -k) argument (or in the configuration file).
|
||||
Old backups are only removed if rsync completes successfully, so that they are not deleted when the new backup may be incomplete.
|
||||
The only rsync error that is ignored is return code 24 (source files vanished during the transfer), which is common when backing up a live system.
|
||||
|
||||
## Remote backup
|
||||
> **Warning**
|
||||
> This feature is experimental
|
||||
|
||||
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).
|
||||
It's possible to use a remote server as destination for the backup. Just use the --ssh-user and --ssh-host arguments (or set them in the configuration file).
|
||||
For this to work, rsync must be installed on the server too.
|
||||
|
||||
### Server authentication
|
||||
|
||||
+30
-6
@@ -1,4 +1,4 @@
|
||||
.TH SIMPLE_BACKUP 1 2025-03-30 SIMPLE_BACKUP 4.1.6
|
||||
.TH SIMPLE_BACKUP 1 2026-10-08 SIMPLE_BACKUP 4.3.0
|
||||
.SH NAME
|
||||
simple_backup \- Backup files and folders using rsync
|
||||
.SH SYNOPSIS
|
||||
@@ -52,7 +52,15 @@ or to use single or double quotes around them.
|
||||
.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).
|
||||
inside this directory the actual backup directory (using the current date and time, for example
|
||||
\(aq2026\-10\-08_17\-34\-47\(aq).
|
||||
.RS
|
||||
.P
|
||||
Backups are best stored on a Linux filesystem (e.g. ext4 or btrfs). FAT and exFAT filesystems
|
||||
are supported, but they don\(aqt support hard links, so each backup will be a full copy instead
|
||||
of only storing files that changed since the previous one. File ownership and permissions are
|
||||
not preserved either.
|
||||
.RE
|
||||
.TP
|
||||
.B \-e, \-\-exclude FILE|DIR|PATTERN [FILE|...]]
|
||||
Specify files, directories or patterns to exclude from the backup. Matching files and directories
|
||||
@@ -61,6 +69,10 @@ will not be copied. Multiple elements can be specified, in the same way as for t
|
||||
.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).
|
||||
Old backups are only removed if rsync completes successfully, to avoid deleting them when the
|
||||
new backup may be incomplete. rsync return code 24 (source files vanished during the transfer)
|
||||
is treated as success, while any other error (including return code 23, partial transfer due to error)
|
||||
will prevent old backups from being removed. This does not apply when using \-\-remove\-before\-backup.
|
||||
.TP
|
||||
.B \-u, \-\-user USERNAME
|
||||
Explicitly specify the user running the backup (in case it is needed for home directory expansion).
|
||||
@@ -86,6 +98,9 @@ if saving bandwith is needed.
|
||||
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.
|
||||
Note that if the backup fails after old backups have been removed, only the backups kept
|
||||
according to \-\-keep will be left, and the new backup may be incomplete. In particular, with
|
||||
\-\-keep 0 there will be no complete backup available until the next successful one.
|
||||
.TP
|
||||
.B \-\-no\-syslog
|
||||
Don't use systemd journal for logging.
|
||||
@@ -100,7 +115,7 @@ Using \-\-rsync\-options it is possible to manually select which options to use.
|
||||
.P
|
||||
\-a, \-l, \-p, \-t, \-g, \-o, \-c, \-h, \-D, \-H, \-X, \-s
|
||||
.P
|
||||
Options \-r and \-v are used in any case. Not that options must be specified without dash (\-), for example:
|
||||
Options \-r, \-v, \-\-ignore\-missing\-args and \-\-mkpath are used in any case. Note that options must be specified without dash (\-), for example:
|
||||
.P
|
||||
.EX
|
||||
simple_backup \-\-rsync\-options a l p
|
||||
@@ -131,6 +146,14 @@ 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\-\-ssh\-host\(aq and \(aq\-\-ssh\-user\(aq options).
|
||||
.P
|
||||
The output directory is a path on the server. A leading \(aq~\(aq and relative paths refer to the
|
||||
home directory of the user on the server. On the command line, quote \(aq~\(aq so that the local
|
||||
shell doesn\(aqt expand it, for example:
|
||||
.P
|
||||
.EX
|
||||
simple_backup \-\-ssh\-host server \-\-ssh\-user user \-o \(aq~/backups\(aq
|
||||
.EE
|
||||
.SS AUTHENTICATION
|
||||
For authentication, it is possible to use SSH key or password.
|
||||
.P
|
||||
@@ -165,7 +188,8 @@ for details. For this reason, use SSH key authentication if possible.
|
||||
.SH EXIT STATUS
|
||||
.TP
|
||||
.B 0
|
||||
The backup was completed without errors.
|
||||
The backup was completed without errors. This includes rsync return code 24 (some source files
|
||||
vanished during the transfer), which only logs a warning.
|
||||
.TP
|
||||
.B 1
|
||||
No valid inputs selected for backup.
|
||||
@@ -177,13 +201,13 @@ Backup failed because output directory for storing the backup does not exist.
|
||||
Permission denied to access the output directory.
|
||||
.TP
|
||||
.B 4
|
||||
rsync error (rsync returned a non-zero value).
|
||||
rsync error (rsync returned a value other than 0 or 24).
|
||||
.TP
|
||||
.B 5
|
||||
SSH connection failed.
|
||||
.TP
|
||||
.B 6
|
||||
Bad configuration file.
|
||||
Bad configuration file or options (for example, only one of SSH host and SSH user specified).
|
||||
.SH SEE ALSO
|
||||
.BR rsync (1)
|
||||
.SH AUTHORS
|
||||
|
||||
+46
-1
@@ -1,3 +1,48 @@
|
||||
[build-system]
|
||||
requires = ['setuptools']
|
||||
requires = ['setuptools>=77']
|
||||
build-backend = 'setuptools.build_meta'
|
||||
|
||||
[project]
|
||||
name = "simple_backup"
|
||||
dynamic = ["version"]
|
||||
description = "Simple backup script that uses rsync to copy files"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
license = "GPL-3.0-or-later"
|
||||
license-files = ["LICENSE"]
|
||||
authors = [
|
||||
{ name = "Daniele Fucini", email = "dfucini@gmail.com" }
|
||||
]
|
||||
maintainers = [
|
||||
{ name = "Daniele Fucini", email = "dfucini@gmail.com" }
|
||||
]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Environment :: Console",
|
||||
"Natural Language :: English",
|
||||
"Operating System :: POSIX :: Linux",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Programming Language :: Python :: 3.14",
|
||||
"Topic :: System :: Archiving :: Backup",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
JOURNAL = ["systemd-python"]
|
||||
NOTIFICATIONS = ["dbus-python"]
|
||||
REMOTE = ["paramiko>=3.2"]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://git.shouldnt.work/fuxino/simple_backup"
|
||||
|
||||
[project.scripts]
|
||||
simple_backup = "simple_backup.simple_backup:simple_backup"
|
||||
|
||||
[tool.setuptools]
|
||||
packages = ["simple_backup"]
|
||||
|
||||
[tool.setuptools.dynamic]
|
||||
version = { attr = "simple_backup.__version__" }
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
[metadata]
|
||||
name = simple_backup
|
||||
version = attr: simple_backup.__version__
|
||||
description = Simple backup script using rsync
|
||||
long_description = file: README.md
|
||||
author = Daniele Fucini
|
||||
author_email = dfucini@gmail.com
|
||||
license = GPL3
|
||||
url = https://git.shouldnt.work/fuxino
|
||||
classifiers =
|
||||
Development Status :: 4 - Beta
|
||||
Environment :: Console
|
||||
License :: OSI Approved :: GNU General Public License v3 (GPLv3)
|
||||
Natural Language :: English
|
||||
Operating System :: POSIX :: Linux
|
||||
Programming Language :: Python :: 3.10
|
||||
Programming Language :: Python :: 3.11
|
||||
Programming Language :: Python :: 3.12
|
||||
Programming Language :: Python :: 3.13
|
||||
Topic :: System :: Archiving :: Backup
|
||||
|
||||
[options]
|
||||
packages = simple_backup
|
||||
python_requires = >=3.10
|
||||
install_requires =
|
||||
python-dotenv
|
||||
|
||||
[options.extras_require]
|
||||
JOURNAL =
|
||||
systemd-python
|
||||
NOTIFICATIONS =
|
||||
dbus-python
|
||||
REMOTE =
|
||||
paramiko
|
||||
|
||||
[options.entry_points]
|
||||
console_scripts =
|
||||
simple_backup = simple_backup.simple_backup:simple_backup
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Init."""
|
||||
|
||||
__version__ = '4.1.6'
|
||||
__version__ = '4.3.0'
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# Files and directories to backup. Multiple items can be separated using a comma (','). It is possible to use wildcards (i.e. '*' to match multiple characters and '~' for the user's home directory).
|
||||
inputs=/home/user
|
||||
|
||||
# Output directory.
|
||||
# Output directory. For remote backups, this is a path on the server ('~' and relative paths refer to the home directory on the server).
|
||||
backup_dir=/media/Backup
|
||||
|
||||
# Files, directories and patterns to exclude from the backup. Multiple items can be separated using a comma.
|
||||
@@ -18,5 +18,5 @@ keep=-1
|
||||
# ssh_host=
|
||||
# ssh_user=
|
||||
# ssh_keyfile=
|
||||
# remote_sudo=
|
||||
# numeric_ids=
|
||||
# remote_sudo=false
|
||||
# numeric_ids=false
|
||||
|
||||
+312
-222
@@ -11,8 +11,10 @@ Classes:
|
||||
Backup
|
||||
"""
|
||||
|
||||
# Avoid evaluating annotations at definition time, as they reference optional modules (paramiko)
|
||||
from __future__ import annotations
|
||||
|
||||
# Import libraries
|
||||
import traceback
|
||||
import sys
|
||||
import os
|
||||
import pwd
|
||||
@@ -32,13 +34,8 @@ from tempfile import mkstemp
|
||||
from getpass import GetPassWarning, getpass
|
||||
from glob import glob
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
warnings.filterwarnings('error')
|
||||
|
||||
try:
|
||||
import paramiko
|
||||
from paramiko import RSAKey, Ed25519Key, ECDSAKey, DSSKey
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
@@ -50,10 +47,9 @@ except ImportError:
|
||||
try:
|
||||
import dbus
|
||||
except ImportError:
|
||||
pass
|
||||
dbus = None
|
||||
|
||||
|
||||
load_dotenv()
|
||||
logging.getLogger().setLevel(logging.DEBUG)
|
||||
logger = logging.getLogger(os.path.basename(__file__))
|
||||
c_handler = StreamHandler()
|
||||
@@ -73,6 +69,9 @@ if journal:
|
||||
P = ParamSpec('P')
|
||||
R = TypeVar('R')
|
||||
|
||||
# Seconds to wait for the TCP connection to the SSH server
|
||||
SSH_TIMEOUT = 15
|
||||
|
||||
|
||||
def timing(func: Callable[P, R]) -> Callable[P, R]:
|
||||
"""Decorator to measure execution time of a function
|
||||
@@ -159,7 +158,7 @@ class Backup:
|
||||
self._remote = False
|
||||
self._ssh = None
|
||||
self._password_auth = False
|
||||
self._password = None
|
||||
self._removed_count = 0
|
||||
|
||||
def check_params(self, homedir: str = '') -> int:
|
||||
"""Check if parameters for the backup are valid"""
|
||||
@@ -180,37 +179,66 @@ class Backup:
|
||||
self._remote = True
|
||||
|
||||
if self._remote:
|
||||
self._ssh = self._ssh_connect(homedir)
|
||||
try:
|
||||
self._ssh = self._ssh_connect(homedir)
|
||||
except OSError as e:
|
||||
# Unknown hostname, connection refused, unreachable host or timeout
|
||||
logger.critical('Can\'t connect to the server %s', self.ssh_host)
|
||||
logger.critical(e)
|
||||
|
||||
return 5
|
||||
|
||||
if self._ssh is None:
|
||||
return 5
|
||||
|
||||
_, stdout, _ = self._ssh.exec_command(
|
||||
f'if [ -d "{self.output}" ]; then echo "ok"; fi')
|
||||
output = self._resolve_remote_path(self.output)
|
||||
|
||||
output = stdout.read().decode('utf-8').strip()
|
||||
|
||||
if output != 'ok':
|
||||
if output is None:
|
||||
logger.critical('Output path for backup does not exist')
|
||||
|
||||
return 2
|
||||
|
||||
self.output = output
|
||||
else:
|
||||
if not os.path.isdir(self.output):
|
||||
logger.critical('Output path for backup does not exist')
|
||||
|
||||
return 2
|
||||
|
||||
self.output = os.path.abspath(self.output)
|
||||
self.output = os.path.abspath(self.output)
|
||||
|
||||
if self.keep is None:
|
||||
self.keep = -1
|
||||
|
||||
return 0
|
||||
|
||||
def _resolve_remote_path(self, path: str) -> Optional[str]:
|
||||
"""Return the absolute path of a directory on the server, or None if it doesn't exist"""
|
||||
|
||||
assert self._ssh is not None
|
||||
|
||||
# Leave a leading '~' unquoted, so that the server's shell expands it to the remote home.
|
||||
# Relative paths are resolved from the remote home, where the command starts
|
||||
if path == '~' or path.startswith('~/'):
|
||||
target = f'~/{shlex.quote(path[2:])}'
|
||||
else:
|
||||
target = shlex.quote(path)
|
||||
|
||||
_, stdout, _ = self._ssh.exec_command(f'cd -- {target} && pwd')
|
||||
|
||||
output = stdout.read().decode('utf-8').strip().split('\n')[-1]
|
||||
|
||||
if not output.startswith('/'):
|
||||
return None
|
||||
|
||||
return output
|
||||
|
||||
# Function to create the actual backup directory
|
||||
def define_backup_dir(self) -> None:
|
||||
"""Define the actual backup dir"""
|
||||
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
# Avoid ':', which is not allowed on FAT/exFAT filesystems.
|
||||
# Backups created with the old format ('%Y-%m-%d %H:%M:%S') still sort correctly
|
||||
now = datetime.now().strftime('%Y-%m-%d_%H-%M-%S')
|
||||
self._output_dir = f'{self.output}/simple_backup/{now}'
|
||||
|
||||
if self._remote:
|
||||
@@ -222,10 +250,23 @@ class Backup:
|
||||
if self._remote:
|
||||
assert self._ssh is not None
|
||||
|
||||
_, stdout, _ = self._ssh.exec_command(
|
||||
f'ls {self.output}/simple_backup')
|
||||
backup_dir = shlex.quote(self.output + '/simple_backup/')
|
||||
|
||||
dirs = stdout.read().decode('utf-8').strip().split('\n')
|
||||
# List only directories, and nothing (without errors) if no backup was made yet
|
||||
_, stdout, stderr = self._ssh.exec_command(
|
||||
f'if [ -d {backup_dir} ]; then find {backup_dir} -mindepth 1 -maxdepth 1 -type d; fi')
|
||||
|
||||
output = stdout.read().decode('utf-8')
|
||||
err = stderr.read().decode('utf-8').strip()
|
||||
|
||||
if err != '':
|
||||
# Don't risk removing the wrong directories if the list may be incomplete
|
||||
logger.error('Cannot list old backups on the server. Old backups will not be removed')
|
||||
logger.error(err)
|
||||
|
||||
return
|
||||
|
||||
dirs = [os.path.basename(d) for d in output.split('\n') if d != '']
|
||||
|
||||
n_backup = len(dirs)
|
||||
|
||||
@@ -241,24 +282,30 @@ class Backup:
|
||||
for i in range(n_backup - self.keep):
|
||||
if self.remote_sudo:
|
||||
_, _, stderr = self._ssh.exec_command(
|
||||
f'sudo rm -r "{self.output}/simple_backup/{dirs[i]}"')
|
||||
f'sudo rm -r {shlex.quote(f"{self.output}/simple_backup/{dirs[i]}")}')
|
||||
else:
|
||||
_, _, stderr = self._ssh.exec_command(
|
||||
f'rm -r "{self.output}/simple_backup/{dirs[i]}"')
|
||||
f'rm -r {shlex.quote(f"{self.output}/simple_backup/{dirs[i]}")}')
|
||||
|
||||
err = stderr.read().decode('utf-8').strip().split('\n')[0]
|
||||
|
||||
if err != '':
|
||||
logger.error(
|
||||
'Error while removing backup %s.', {dirs[i]})
|
||||
'Error while removing backup %s.', dirs[i])
|
||||
logger.error(err)
|
||||
else:
|
||||
count += 1
|
||||
else:
|
||||
try:
|
||||
dirs = os.listdir(f'{self.output}/simple_backup')
|
||||
# Only count directories, so that stray files don't count as backups
|
||||
dirs = [f.name for f in os.scandir(f'{self.output}/simple_backup')
|
||||
if f.is_dir(follow_symlinks=False)]
|
||||
except FileNotFoundError:
|
||||
return
|
||||
except PermissionError:
|
||||
logger.error('Cannot list old backups. Permission denied. Old backups will not be removed')
|
||||
|
||||
return
|
||||
|
||||
n_backup = len(dirs)
|
||||
|
||||
@@ -282,6 +329,8 @@ class Backup:
|
||||
logger.error(
|
||||
'Error while removing backup %s. Permission denied', dirs[i])
|
||||
|
||||
self._removed_count = count
|
||||
|
||||
if count == 1:
|
||||
logger.info('Removed %d backup', count)
|
||||
elif count > 1:
|
||||
@@ -296,7 +345,7 @@ class Backup:
|
||||
sys.exit(5)
|
||||
|
||||
_, stdout, _ = self._ssh.exec_command(
|
||||
f'find {self.output}/simple_backup/ -mindepth 1 -maxdepth 1 -type d | sort')
|
||||
f'find {shlex.quote(self.output + "/simple_backup/")} -mindepth 1 -maxdepth 1 -type d | sort')
|
||||
output = stdout.read().decode('utf-8').strip().split('\n')
|
||||
|
||||
if output[-1] != '':
|
||||
@@ -315,10 +364,7 @@ class Backup:
|
||||
logger.critical(
|
||||
'Cannot access the backup directory. Permission denied')
|
||||
|
||||
try:
|
||||
_notify('Backup failed (check log for details)')
|
||||
except NameError:
|
||||
pass
|
||||
_notify('Backup failed (check log for details)')
|
||||
|
||||
sys.exit(3)
|
||||
|
||||
@@ -327,7 +373,7 @@ class Backup:
|
||||
except IndexError:
|
||||
logger.info('No previous backups available')
|
||||
|
||||
def _ssh_connect(self, homedir: str = '') -> paramiko.client.SSHClient:
|
||||
def _ssh_connect(self, homedir: str = '') -> Optional[paramiko.client.SSHClient]:
|
||||
try:
|
||||
ssh = paramiko.SSHClient()
|
||||
except NameError:
|
||||
@@ -335,6 +381,18 @@ class Backup:
|
||||
|
||||
return None
|
||||
|
||||
connected = None
|
||||
|
||||
try:
|
||||
connected = self._ssh_login(ssh, homedir)
|
||||
|
||||
return connected
|
||||
finally:
|
||||
# Don't leave a half-open connection (e.g. after failed authentication)
|
||||
if connected is None:
|
||||
ssh.close()
|
||||
|
||||
def _ssh_login(self, ssh: paramiko.client.SSHClient, homedir: str) -> Optional[paramiko.client.SSHClient]:
|
||||
try:
|
||||
ssh.load_host_keys(filename=f'{homedir}/.ssh/known_hosts')
|
||||
except FileNotFoundError:
|
||||
@@ -343,16 +401,25 @@ class Backup:
|
||||
ssh.set_missing_host_key_policy(paramiko.WarningPolicy())
|
||||
|
||||
try:
|
||||
ssh.connect(self.ssh_host, username=self.ssh_user)
|
||||
# WarningPolicy emits a UserWarning for unknown host keys
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter('error', UserWarning)
|
||||
ssh.connect(self.ssh_host, username=self.ssh_user, timeout=SSH_TIMEOUT)
|
||||
|
||||
return ssh
|
||||
except UserWarning:
|
||||
k = input(
|
||||
f'Unknown key for host {self.ssh_host}. Continue anyway? (Y/N) ')
|
||||
try:
|
||||
k = input(
|
||||
f'Unknown key for host {self.ssh_host}. Continue anyway? (Y/N) ')
|
||||
except EOFError:
|
||||
# No terminal available (e.g. cron or systemd)
|
||||
k = ''
|
||||
|
||||
if k[0].upper() == 'Y':
|
||||
if k.strip().upper().startswith('Y'):
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
else:
|
||||
logger.critical('Unknown host key for %s. Connection aborted', self.ssh_host)
|
||||
|
||||
return None
|
||||
except paramiko.BadHostKeyException as e:
|
||||
logger.critical('Can\'t connect to the server.')
|
||||
@@ -363,7 +430,7 @@ class Backup:
|
||||
pass
|
||||
|
||||
try:
|
||||
ssh.connect(self.ssh_host, username=self.ssh_user)
|
||||
ssh.connect(self.ssh_host, username=self.ssh_user, timeout=SSH_TIMEOUT)
|
||||
|
||||
return ssh
|
||||
except paramiko.SSHException:
|
||||
@@ -371,9 +438,13 @@ class Backup:
|
||||
|
||||
if self.ssh_keyfile is None:
|
||||
try:
|
||||
password = getpass(
|
||||
f'{self.ssh_user}@{self.ssh_host}\'s password: ')
|
||||
ssh.connect(self.ssh_host, username=self.ssh_user,
|
||||
# Fail instead of reading the password with echo if no terminal is available
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter('error', GetPassWarning)
|
||||
password = getpass(
|
||||
f'{self.ssh_user}@{self.ssh_host}\'s password: ')
|
||||
|
||||
ssh.connect(self.ssh_host, username=self.ssh_user, timeout=SSH_TIMEOUT,
|
||||
password=password)
|
||||
|
||||
self._password_auth = True
|
||||
@@ -391,51 +462,13 @@ class Backup:
|
||||
|
||||
return None
|
||||
|
||||
pkey = None
|
||||
pkey = self._load_ssh_key()
|
||||
|
||||
if pkey is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
pkey = RSAKey.from_private_key_file(self.ssh_keyfile)
|
||||
except paramiko.PasswordRequiredException:
|
||||
password = getpass(
|
||||
f'Enter passwphrase for key \'{self.ssh_keyfile}\': ')
|
||||
|
||||
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.ssh_host, username=self.ssh_user, pkey=pkey)
|
||||
ssh.connect(self.ssh_host, username=self.ssh_user, timeout=SSH_TIMEOUT, pkey=pkey)
|
||||
except paramiko.SSHException:
|
||||
logger.critical('SSH connection to server failed')
|
||||
|
||||
@@ -443,6 +476,37 @@ class Backup:
|
||||
|
||||
return ssh
|
||||
|
||||
def _load_ssh_key(self) -> Optional[paramiko.PKey]:
|
||||
try:
|
||||
return paramiko.PKey.from_path(self.ssh_keyfile)
|
||||
except TypeError:
|
||||
# Raised when the key is encrypted and no passphrase was given
|
||||
pass
|
||||
except (OSError, ValueError, paramiko.UnknownKeyType) as e:
|
||||
logger.critical('Unable to load SSH key %s', self.ssh_keyfile)
|
||||
logger.critical(e)
|
||||
|
||||
return None
|
||||
|
||||
try:
|
||||
# Fail instead of reading the passphrase with echo if no terminal is available
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter('error', GetPassWarning)
|
||||
passphrase = getpass(
|
||||
f'Enter passphrase for key \'{self.ssh_keyfile}\': ')
|
||||
except GetPassWarning as e:
|
||||
logger.critical('Unable to get passphrase')
|
||||
logger.critical(e)
|
||||
|
||||
return None
|
||||
|
||||
try:
|
||||
return paramiko.PKey.from_path(self.ssh_keyfile, passphrase.encode())
|
||||
except (ValueError, paramiko.SSHException):
|
||||
logger.critical('Unable to load SSH key %s, wrong passphrase?', self.ssh_keyfile)
|
||||
|
||||
return None
|
||||
|
||||
def _returncode_log(self, returncode: int) -> None:
|
||||
match returncode:
|
||||
case 2:
|
||||
@@ -474,7 +538,7 @@ class Backup:
|
||||
'Rsync error (return code 23) - Partial transfer due to error')
|
||||
case 24:
|
||||
logger.warning(
|
||||
'Rsync error (return code 24) - Partial transfer due to vanished source files')
|
||||
'Rsync warning (return code 24) - Some source files vanished during the transfer')
|
||||
case 30:
|
||||
logger.error(
|
||||
'Rsync error (return code 30) - Timeout in data send/receive')
|
||||
@@ -490,25 +554,43 @@ class Backup:
|
||||
def run(self) -> int:
|
||||
"""Perform the backup"""
|
||||
|
||||
try:
|
||||
return self._run()
|
||||
finally:
|
||||
self._remove_temp_files()
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the SSH connection, if open"""
|
||||
|
||||
if self._ssh is not None:
|
||||
self._ssh.close()
|
||||
self._ssh = None
|
||||
|
||||
def _remove_temp_files(self) -> None:
|
||||
for path in [self._inputs_path, self._exclude_path]:
|
||||
if path != '':
|
||||
try:
|
||||
os.remove(path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
def _run(self) -> int:
|
||||
logger.info('Starting backup...')
|
||||
|
||||
try:
|
||||
_notify('Starting backup...')
|
||||
except NameError:
|
||||
pass
|
||||
_notify('Starting backup...')
|
||||
|
||||
self.define_backup_dir()
|
||||
self.find_last_backup()
|
||||
|
||||
_, self._inputs_path = mkstemp(prefix='tmp_inputs', text=True)
|
||||
fd, self._inputs_path = mkstemp(prefix='tmp_inputs', text=True)
|
||||
count = 0
|
||||
|
||||
with open(self._inputs_path, 'w', encoding='utf-8') as fp:
|
||||
with os.fdopen(fd, 'w', encoding='utf-8') as fp:
|
||||
for i in self.inputs:
|
||||
if not os.path.exists(i):
|
||||
logger.warning('Input %s not found. Skipping', i)
|
||||
else:
|
||||
fp.write(i)
|
||||
# rsync reads --files-from paths relative to the source ('/')
|
||||
fp.write(os.path.abspath(i))
|
||||
fp.write('\n')
|
||||
count += 1
|
||||
|
||||
@@ -516,16 +598,13 @@ class Backup:
|
||||
logger.info(
|
||||
'No existing files or directories specified for backup. Nothing to do')
|
||||
|
||||
try:
|
||||
_notify('Backup finished. No files copied')
|
||||
except NameError:
|
||||
pass
|
||||
_notify('Backup finished. No files copied')
|
||||
|
||||
return 1
|
||||
|
||||
_, self._exclude_path = mkstemp(prefix='tmp_exclude', text=True)
|
||||
fd, self._exclude_path = mkstemp(prefix='tmp_exclude', text=True)
|
||||
|
||||
with open(self._exclude_path, 'w', encoding='utf-8') as fp:
|
||||
with os.fdopen(fd, 'w', encoding='utf-8') as fp:
|
||||
if self.exclude is not None:
|
||||
for e in self.exclude:
|
||||
fp.write(e)
|
||||
@@ -534,28 +613,32 @@ class Backup:
|
||||
if self.keep != -1 and self._remove_before:
|
||||
self.remove_old_backups()
|
||||
|
||||
# Only look for the last backup now, as it may have been removed above
|
||||
self.find_last_backup()
|
||||
|
||||
logger.info('Copying files. This may take a long time...')
|
||||
|
||||
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}"'
|
||||
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}"'
|
||||
# Build the argument list directly, so paths with spaces or quotes are passed unchanged
|
||||
args = ['/usr/bin/rsync', *self.options.split()]
|
||||
|
||||
euid = os.geteuid()
|
||||
if self._last_backup != '':
|
||||
args.append(f'--link-dest={self._last_backup}')
|
||||
|
||||
if euid == 0 and self.ssh_keyfile is not None:
|
||||
rsync = f'{rsync} -e \'ssh -i {self.ssh_keyfile} -o StrictHostKeyChecking=no\''
|
||||
args.extend([f'--exclude-from={self._exclude_path}', f'--files-from={self._inputs_path}',
|
||||
'/', f'{self._server}{self._output_dir}'])
|
||||
|
||||
# rsync splits the -e command itself, so quote the parts that may contain spaces
|
||||
if self.ssh_keyfile is not None:
|
||||
ssh = f'ssh -i {shlex.quote(self.ssh_keyfile)} -o StrictHostKeyChecking=accept-new'
|
||||
elif self._password_auth and which('sshpass'):
|
||||
rsync = f'{rsync} -e \'sshpass -e ssh -l {self.ssh_user} -o StrictHostKeyChecking=no\''
|
||||
ssh = f'sshpass -e ssh -l {shlex.quote(self.ssh_user)} -o StrictHostKeyChecking=accept-new'
|
||||
else:
|
||||
rsync = f'{rsync} -e \'ssh -o StrictHostKeyChecking=no\''
|
||||
ssh = 'ssh -o StrictHostKeyChecking=accept-new'
|
||||
|
||||
args.extend(['-e', ssh])
|
||||
|
||||
if self._remote and self.remote_sudo:
|
||||
rsync = f'{rsync} --rsync-path="sudo rsync"'
|
||||
|
||||
args = shlex.split(rsync)
|
||||
args.append('--rsync-path=sudo rsync')
|
||||
|
||||
with Popen(args, stdin=PIPE, stdout=PIPE, stderr=STDOUT, shell=False) as p:
|
||||
output: Union[bytes, List[str]]
|
||||
@@ -585,70 +668,49 @@ class Backup:
|
||||
else:
|
||||
logger.error(output)
|
||||
|
||||
if self.keep != -1 and not self._remove_before:
|
||||
self.remove_old_backups()
|
||||
|
||||
os.remove(self._inputs_path)
|
||||
os.remove(self._exclude_path)
|
||||
|
||||
if self._remote:
|
||||
assert self._ssh is not None
|
||||
|
||||
_, stdout, _ = self._ssh.exec_command(
|
||||
f'if [ -d "{self._output_dir}" ]; then echo "ok"; fi')
|
||||
|
||||
output = stdout.read().decode('utf-8').strip()
|
||||
|
||||
if output == 'ok':
|
||||
logger.info('Backup completed')
|
||||
|
||||
try:
|
||||
_notify('Backup completed')
|
||||
except NameError:
|
||||
pass
|
||||
if self._removed_count > 0 and returncode not in [0, 24]:
|
||||
# With --remove-before-backup, old backups are already gone if the new one failed
|
||||
if self.keep == 0:
|
||||
message = 'All old backups were removed before the backup, and the new backup may be ' +\
|
||||
'incomplete. No complete backup is available'
|
||||
else:
|
||||
logger.error('Backup failed')
|
||||
message = 'Old backups were removed before the backup, and the new backup may be incomplete'
|
||||
|
||||
try:
|
||||
_notify('Backup failed (check log for details)')
|
||||
except NameError:
|
||||
pass
|
||||
logger.error(message)
|
||||
_notify(message)
|
||||
|
||||
if self._ssh:
|
||||
self._ssh.close()
|
||||
else:
|
||||
if returncode != 0:
|
||||
logger.error(
|
||||
'Some errors occurred while performing the backup')
|
||||
if self.keep != -1 and not self._remove_before:
|
||||
# Don't delete old backups if the new one may be incomplete
|
||||
if returncode in [0, 24]:
|
||||
self.remove_old_backups()
|
||||
else:
|
||||
logger.warning('Backup not completed successfully. Old backups will not be removed')
|
||||
|
||||
try:
|
||||
_notify(
|
||||
'Some errors occurred while performing the backup. Check log for details')
|
||||
except NameError:
|
||||
pass
|
||||
# Files vanishing during the transfer is normal for a live system (return code 24)
|
||||
if returncode not in [0, 24]:
|
||||
logger.error(
|
||||
'Some errors occurred while performing the backup')
|
||||
|
||||
return 4
|
||||
_notify(
|
||||
'Some errors occurred while performing the backup. Check log for details')
|
||||
|
||||
logger.info('Backup completed')
|
||||
return 4
|
||||
|
||||
try:
|
||||
_notify('Backup completed')
|
||||
except NameError:
|
||||
pass
|
||||
logger.info('Backup completed')
|
||||
|
||||
return 0
|
||||
_notify('Backup completed')
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def _detect_user() -> Optional[str]:
|
||||
if os.geteuid() == 0:
|
||||
return os.getenv('SUDO_USER') or os.getenv('DOAS_USER')
|
||||
|
||||
return os.getenv('USER')
|
||||
|
||||
|
||||
def _parse_arguments() -> argparse.Namespace:
|
||||
euid = os.geteuid()
|
||||
|
||||
if euid == 0:
|
||||
user = os.getenv('SUDO_USER')
|
||||
else:
|
||||
user = os.getenv('USER')
|
||||
|
||||
homedir = os.path.expanduser(f'~{user}')
|
||||
|
||||
parser = argparse.ArgumentParser(prog='simple_backup',
|
||||
description='Simple backup script written in Python that uses rsync to copy files',
|
||||
epilog='See simple_backup(1) manpage for full documentation',
|
||||
@@ -656,7 +718,7 @@ def _parse_arguments() -> argparse.Namespace:
|
||||
|
||||
parser.add_argument('-v', '--verbose', action='store_true',
|
||||
help='More verbose output')
|
||||
parser.add_argument('-c', '--config', default=f'{homedir}/.config/simple_backup/simple_backup.conf',
|
||||
parser.add_argument('-c', '--config', default='~/.config/simple_backup/simple_backup.conf',
|
||||
help='Specify location of configuration file')
|
||||
parser.add_argument('-i', '--inputs', nargs='+',
|
||||
help='Paths/files to backup')
|
||||
@@ -695,6 +757,15 @@ def _parse_arguments() -> argparse.Namespace:
|
||||
return args
|
||||
|
||||
|
||||
def _expand_home(path: str, user: str) -> str:
|
||||
"""Expand a leading '~' to the home directory of user (other '~' are left as they are)"""
|
||||
|
||||
if path == '~' or path.startswith('~/'):
|
||||
path = f'~{user}{path[1:]}'
|
||||
|
||||
return os.path.expanduser(path)
|
||||
|
||||
|
||||
def _expand_inputs(inputs, user: Optional[str] = None):
|
||||
expanded_inputs = []
|
||||
|
||||
@@ -703,11 +774,11 @@ def _expand_inputs(inputs, user: Optional[str] = None):
|
||||
continue
|
||||
|
||||
if user is not None:
|
||||
i_ex = glob(os.path.expanduser(i.replace('~', f'~{user}')))
|
||||
i_ex = glob(_expand_home(i, user))
|
||||
else:
|
||||
i_ex = glob(i)
|
||||
|
||||
if '~' in i:
|
||||
if i.startswith('~'):
|
||||
logger.warning('Cannot expand \'~\'. No user specified')
|
||||
|
||||
if len(i_ex) == 0:
|
||||
@@ -739,7 +810,8 @@ def _read_config(config_file, user: Optional[str] = None):
|
||||
|
||||
return config_args
|
||||
|
||||
config = configparser.ConfigParser()
|
||||
# Disable interpolation, so that '%' can be used in paths and patterns
|
||||
config = configparser.ConfigParser(interpolation=None)
|
||||
config.read(config_file)
|
||||
|
||||
section = 'backup'
|
||||
@@ -751,24 +823,18 @@ def _read_config(config_file, user: Optional[str] = None):
|
||||
section = 'default'
|
||||
inputs = config.get(section, 'inputs')
|
||||
|
||||
inputs = inputs.split(',')
|
||||
inputs = [i.strip() for i in inputs.split(',')]
|
||||
inputs = _expand_inputs(inputs, user)
|
||||
inputs = list(set(inputs))
|
||||
|
||||
config_args['inputs'] = inputs
|
||||
|
||||
output = config.get(section, 'backup_dir')
|
||||
|
||||
if user is not None:
|
||||
output = os.path.expanduser(output.replace('~', f'~{user}'))
|
||||
elif user is None and '~' in output:
|
||||
logger.warning('Cannot expand \'~\', no user specified')
|
||||
|
||||
config_args['output'] = output
|
||||
# '~' is expanded later, since for remote backups it refers to the home on the server
|
||||
config_args['output'] = config.get(section, 'backup_dir')
|
||||
|
||||
try:
|
||||
exclude = config.get(section, 'exclude')
|
||||
exclude = exclude.split(',')
|
||||
exclude = [e.strip() for e in exclude.split(',') if e.strip() != '']
|
||||
except configparser.NoOptionError:
|
||||
exclude = []
|
||||
|
||||
@@ -781,36 +847,19 @@ def _read_config(config_file, user: Optional[str] = None):
|
||||
|
||||
config_args['keep'] = keep
|
||||
|
||||
try:
|
||||
ssh_host = config.get('server', 'ssh_host')
|
||||
ssh_user = config.get('server', 'ssh_user')
|
||||
except (configparser.NoSectionError, configparser.NoOptionError):
|
||||
ssh_host = None
|
||||
ssh_user = None
|
||||
# Read host and user separately, so that a missing one is reported instead of silently
|
||||
# disabling remote backup. Empty values count as not set
|
||||
config_args['ssh_host'] = config.get('server', 'ssh_host', fallback='').strip() or None
|
||||
config_args['ssh_user'] = config.get('server', 'ssh_user', fallback='').strip() or None
|
||||
|
||||
config_args['ssh_host'] = ssh_host
|
||||
config_args['ssh_user'] = ssh_user
|
||||
config_args['ssh_keyfile'] = config.get('server', 'ssh_keyfile', fallback='').strip() or None
|
||||
|
||||
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
|
||||
|
||||
config_args['numeric_ids'] = numeric_ids
|
||||
# Empty values (as in the example config) count as false
|
||||
for key in ['remote_sudo', 'numeric_ids']:
|
||||
if config.get('server', key, fallback='').strip() == '':
|
||||
config_args[key] = False
|
||||
else:
|
||||
config_args[key] = config.getboolean('server', key)
|
||||
|
||||
return config_args
|
||||
|
||||
@@ -833,12 +882,20 @@ def _target_uid() -> int | None:
|
||||
return None
|
||||
|
||||
|
||||
_notify_failed = False
|
||||
|
||||
|
||||
def _notify(text: str) -> None:
|
||||
global _notify_failed
|
||||
|
||||
if dbus is None or _notify_failed:
|
||||
return
|
||||
|
||||
euid = os.geteuid()
|
||||
uid = _target_uid()
|
||||
|
||||
if uid is None:
|
||||
print('notify: no target uid (neither SUDO_UID nor DOAS_USER set)', file=sys.stderr)
|
||||
logger.debug('Cannot send desktop notification: user not found')
|
||||
|
||||
return
|
||||
|
||||
@@ -851,8 +908,10 @@ def _notify(text: str) -> None:
|
||||
'/org/freedesktop/Notifications')
|
||||
obj = dbus.Interface(obj, 'org.freedesktop.Notifications')
|
||||
obj.Notify('simple_backup', 0, '', 'simple_backup', text, [], {'urgency': dbus.Byte(1)}, 10000)
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
except Exception as e:
|
||||
# Report the failure only once (e.g. no session bus available), then stop trying
|
||||
_notify_failed = True
|
||||
logger.warning('Cannot send desktop notification: %s', e)
|
||||
finally:
|
||||
os.seteuid(euid)
|
||||
|
||||
@@ -869,7 +928,7 @@ def simple_backup() -> int:
|
||||
euid = os.geteuid()
|
||||
|
||||
if euid == 0:
|
||||
user = os.getenv('SUDO_USER')
|
||||
user = _detect_user()
|
||||
|
||||
if user is not None:
|
||||
homedir = os.path.expanduser(f'~{user}')
|
||||
@@ -890,28 +949,56 @@ def simple_backup() -> int:
|
||||
except NameError:
|
||||
pass
|
||||
|
||||
config_file = args.config
|
||||
|
||||
# Expand '~' to the home of the user running the backup, not root's
|
||||
if config_file.startswith('~/') and user is not None:
|
||||
config_file = f'~{user}{config_file[1:]}'
|
||||
|
||||
config_file = os.path.expanduser(config_file)
|
||||
|
||||
try:
|
||||
config_args = _read_config(args.config, user)
|
||||
except (configparser.NoSectionError, configparser.NoOptionError):
|
||||
logger.critical('Bad configuration file')
|
||||
config_args = _read_config(config_file, user)
|
||||
except (configparser.Error, ValueError) as e:
|
||||
# configparser.Error: missing sections/options or bad syntax, ValueError: invalid values (e.g. keep=abc)
|
||||
logger.critical('Bad configuration file: %s', e)
|
||||
|
||||
return 6
|
||||
|
||||
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']
|
||||
ssh_host = args.ssh_host if args.ssh_host is not None else config_args['ssh_host']
|
||||
ssh_user = args.ssh_user if args.ssh_user is not None else config_args['ssh_user']
|
||||
|
||||
if (ssh_host is None) != (ssh_user is None):
|
||||
logger.critical('Both SSH host and SSH user are needed for remote backup')
|
||||
|
||||
return 6
|
||||
|
||||
if args.output is not None:
|
||||
output = args.output
|
||||
else:
|
||||
output = config_args['output']
|
||||
|
||||
# For remote backups, '~' is expanded on the server
|
||||
remote = ssh_host is not None and ssh_user is not None
|
||||
|
||||
if output is not None and not remote:
|
||||
if user is not None:
|
||||
output = _expand_home(output, user)
|
||||
elif output.startswith('~'):
|
||||
logger.warning('Cannot expand \'~\', no user specified')
|
||||
|
||||
ssh_keyfile = args.keyfile if args.keyfile is not None else config_args['ssh_keyfile']
|
||||
remote_sudo = args.remote_sudo or config_args['remote_sudo']
|
||||
|
||||
if args.rsync_options is None:
|
||||
rsync_options = ['-a', '-r', '-v', '-h', '-H',
|
||||
'-X', '-s', '--ignore-missing-args', '--mkpath']
|
||||
else:
|
||||
rsync_options = ['-r', '-v']
|
||||
# Options needed in any case (--mkpath creates the backup directory on the first run)
|
||||
rsync_options = ['-r', '-v', '--ignore-missing-args', '--mkpath']
|
||||
|
||||
if args.rsync_options is None:
|
||||
rsync_options.extend(['-a', '-h', '-H', '-X', '-s'])
|
||||
else:
|
||||
for ro in args.rsync_options:
|
||||
rsync_options.append(f'-{ro}')
|
||||
|
||||
@@ -929,12 +1016,15 @@ def simple_backup() -> int:
|
||||
backup = Backup(inputs, output, exclude, keep, rsync_options, ssh_host, ssh_user, ssh_keyfile,
|
||||
remote_sudo, remove_before=args.remove_before_backup, verbose=args.verbose)
|
||||
|
||||
return_code = backup.check_params(homedir)
|
||||
try:
|
||||
return_code = backup.check_params(homedir)
|
||||
|
||||
if return_code == 0:
|
||||
return backup.run()
|
||||
if return_code == 0:
|
||||
return backup.run()
|
||||
|
||||
return return_code
|
||||
return return_code
|
||||
finally:
|
||||
backup.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
Reference in New Issue
Block a user