Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cbca8e4d8
|
||
|
|
54880651be
|
||
|
|
793a30fad8
|
||
|
|
871dd0f49a
|
||
|
|
30e4416f8f
|
||
|
|
d0f888cbb4
|
||
|
|
7ef29cf551
|
||
|
|
4f5499eba9
|
||
|
|
fc165fccbf
|
||
|
|
d79ea56c0f
|
||
|
|
5189910131
|
||
|
|
7d4c43f8ff
|
||
|
|
ac7e6ec21b
|
||
|
|
578d992a19
|
||
|
|
e2d8897a45
|
||
|
|
dadc4b0265
|
||
|
|
7e7d11c520
|
||
|
|
22e0556b50
|
||
|
|
2b91178e94
|
||
|
|
2c2649113d
|
||
|
|
0f4a100ac7
|
||
|
|
60d83151dc
|
||
|
|
47aec456be
|
||
|
|
c715188973
|
||
|
|
57613649c2
|
||
|
|
67dbb49a67
|
||
|
|
e62a668f3e
|
||
|
|
bd8934d57f
|
||
|
|
96fe3c7813
|
||
|
|
9fdb959540
|
||
|
|
eb889beee7
|
||
|
|
2a37eb4172
|
@@ -0,0 +1,38 @@
|
|||||||
|
# PKGBUILD
|
||||||
|
|
||||||
|
# Maintainer: Daniele Fucini <dfucini [at] gmail [dot] com>
|
||||||
|
|
||||||
|
pkgname=simple_backup
|
||||||
|
pkgdesc='Simple backup script that uses rsync to copy files'
|
||||||
|
pkgver=4.1.7
|
||||||
|
pkgrel=1
|
||||||
|
url="https://git.shouldnt.work/fuxino/${pkgname}"
|
||||||
|
arch=('any')
|
||||||
|
license=('GPL-3.0-or-later')
|
||||||
|
makedepends=('git'
|
||||||
|
'python-setuptools'
|
||||||
|
'python-build'
|
||||||
|
'python-installer')
|
||||||
|
depends=('python>=3.10'
|
||||||
|
'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=('011cd5546745e413ac7603b64531def2688c2bf6e4ea19876a67547a1db208ce')
|
||||||
|
|
||||||
|
build()
|
||||||
|
{
|
||||||
|
cd ${srcdir}/${pkgname}
|
||||||
|
python -m build --wheel --no-isolation
|
||||||
|
}
|
||||||
|
|
||||||
|
package()
|
||||||
|
{
|
||||||
|
cd ${srcdir}/${pkgname}
|
||||||
|
python -m installer --destdir=${pkgdir} dist/*.whl
|
||||||
|
install -Dm644 ${pkgname}/${pkgname}.conf ${pkgdir}/usr/share/doc/${pkgname}/${pkgname}.conf
|
||||||
|
install -Dm644 man/${pkgname}.1 ${pkgdir}/usr/share/man/man1/${pkgname}.1
|
||||||
|
}
|
||||||
@@ -20,21 +20,19 @@ The script uses rsync to actually run the backup, so you will have to install it
|
|||||||
sudo pacman -Syu rsync
|
sudo pacman -Syu rsync
|
||||||
```
|
```
|
||||||
|
|
||||||
It's also required to have python-dotenv
|
Optional dependencies are systemd-python to enable using systemd journal for logging, dbus-python for desktop notifications, and paramiko for remote backup.
|
||||||
|
|
||||||
Optional dependencies are systemd-python to enable using systemd journal for logging, and dbus-python for desktop notifications.
|
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
To install the program, first clone the repository:
|
To install the program, first clone the repository:
|
||||||
|
|
||||||
```bash
|
```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:
|
Then install the tools required to build the package:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install --upgrade build wheel
|
pip install --upgrade build
|
||||||
```
|
```
|
||||||
|
|
||||||
Finally, run:
|
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** for the release version
|
||||||
- **simple_backup-git** for the git 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
|
## Remote backup
|
||||||
> **Warning**
|
> **Warning**
|
||||||
> This feature is experimental
|
> 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.
|
For this to work, rsync must be installed on the server too.
|
||||||
|
|
||||||
### Server authentication
|
### Server authentication
|
||||||
|
|||||||
+19
-4
@@ -1,4 +1,4 @@
|
|||||||
.TH SIMPLE_BACKUP 1 2023-06-15 SIMPLE_BACKUP 3.2.6
|
.TH SIMPLE_BACKUP 1 2026-10-08 SIMPLE_BACKUP 4.2.0
|
||||||
.SH NAME
|
.SH NAME
|
||||||
simple_backup \- Backup files and folders using rsync
|
simple_backup \- Backup files and folders using rsync
|
||||||
.SH SYNOPSIS
|
.SH SYNOPSIS
|
||||||
@@ -52,7 +52,15 @@ or to use single or double quotes around them.
|
|||||||
.B \-o, \-\-output DIR
|
.B \-o, \-\-output DIR
|
||||||
Specify the directory where the files will be copied. The program will automatically
|
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
|
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
|
.TP
|
||||||
.B \-e, \-\-exclude FILE|DIR|PATTERN [FILE|...]]
|
.B \-e, \-\-exclude FILE|DIR|PATTERN [FILE|...]]
|
||||||
Specify files, directories or patterns to exclude from the backup. Matching files and directories
|
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
|
.B \-k, \-\-keep N
|
||||||
Specify how many old backups (so excluding the current one) will be kept. The default behavior
|
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).
|
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
|
.TP
|
||||||
.B \-u, \-\-user USERNAME
|
.B \-u, \-\-user USERNAME
|
||||||
Explicitly specify the user running the backup (in case it is needed for home directory expansion).
|
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
|
Remove old backups (if necessary) before creating the new backup. Useful to free some space
|
||||||
before performing the backup.
|
before performing the backup.
|
||||||
Default behavior is to remove old backups after successfully completing 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
|
.TP
|
||||||
.B \-\-no\-syslog
|
.B \-\-no\-syslog
|
||||||
Don't use systemd journal for logging.
|
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
|
.P
|
||||||
\-a, \-l, \-p, \-t, \-g, \-o, \-c, \-h, \-D, \-H, \-X, \-s
|
\-a, \-l, \-p, \-t, \-g, \-o, \-c, \-h, \-D, \-H, \-X, \-s
|
||||||
.P
|
.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
|
.P
|
||||||
.EX
|
.EX
|
||||||
simple_backup \-\-rsync\-options a l p
|
simple_backup \-\-rsync\-options a l p
|
||||||
@@ -187,6 +202,6 @@ Bad configuration file.
|
|||||||
.SH SEE ALSO
|
.SH SEE ALSO
|
||||||
.BR rsync (1)
|
.BR rsync (1)
|
||||||
.SH AUTHORS
|
.SH AUTHORS
|
||||||
.MT https://github.com/Fuxino
|
.MT https://git.shouldnt.work/fuxino
|
||||||
Daniele Fucini
|
Daniele Fucini
|
||||||
.ME
|
.ME
|
||||||
|
|||||||
+46
-1
@@ -1,3 +1,48 @@
|
|||||||
[build-system]
|
[build-system]
|
||||||
requires = ['setuptools']
|
requires = ['setuptools>=77']
|
||||||
build-backend = 'setuptools.build_meta'
|
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"]
|
||||||
|
|
||||||
|
[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,38 +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://github.com/Fuxino/simple_backup
|
|
||||||
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
|
|
||||||
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."""
|
"""Init."""
|
||||||
|
|
||||||
__version__ = '4.1.5'
|
__version__ = '4.2.0'
|
||||||
|
|||||||
+256
-128
@@ -11,9 +11,13 @@ Classes:
|
|||||||
Backup
|
Backup
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# Avoid evaluating annotations at definition time, as they reference optional modules (paramiko)
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
# Import libraries
|
# Import libraries
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
|
import pwd
|
||||||
from typing import Callable, List, Optional, ParamSpec, TypeVar, Union
|
from typing import Callable, List, Optional, ParamSpec, TypeVar, Union
|
||||||
import warnings
|
import warnings
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
@@ -30,10 +34,6 @@ from tempfile import mkstemp
|
|||||||
from getpass import GetPassWarning, getpass
|
from getpass import GetPassWarning, getpass
|
||||||
from glob import glob
|
from glob import glob
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
|
|
||||||
warnings.filterwarnings('error')
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import paramiko
|
import paramiko
|
||||||
from paramiko import RSAKey, Ed25519Key, ECDSAKey, DSSKey
|
from paramiko import RSAKey, Ed25519Key, ECDSAKey, DSSKey
|
||||||
@@ -48,10 +48,9 @@ except ImportError:
|
|||||||
try:
|
try:
|
||||||
import dbus
|
import dbus
|
||||||
except ImportError:
|
except ImportError:
|
||||||
pass
|
dbus = None
|
||||||
|
|
||||||
|
|
||||||
load_dotenv()
|
|
||||||
logging.getLogger().setLevel(logging.DEBUG)
|
logging.getLogger().setLevel(logging.DEBUG)
|
||||||
logger = logging.getLogger(os.path.basename(__file__))
|
logger = logging.getLogger(os.path.basename(__file__))
|
||||||
c_handler = StreamHandler()
|
c_handler = StreamHandler()
|
||||||
@@ -158,17 +157,20 @@ class Backup:
|
|||||||
self._ssh = None
|
self._ssh = None
|
||||||
self._password_auth = False
|
self._password_auth = False
|
||||||
self._password = None
|
self._password = None
|
||||||
|
self._removed_count = 0
|
||||||
|
|
||||||
def check_params(self, homedir: str = '') -> int:
|
def check_params(self, homedir: str = '') -> int:
|
||||||
"""Check if parameters for the backup are valid"""
|
"""Check if parameters for the backup are valid"""
|
||||||
|
|
||||||
if self.inputs is None or len(self.inputs) == 0:
|
if self.inputs is None or len(self.inputs) == 0:
|
||||||
logger.info('No existing files or directories specified for backup. Nothing to do')
|
logger.info(
|
||||||
|
'No existing files or directories specified for backup. Nothing to do')
|
||||||
|
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
if self.output is None:
|
if self.output is None:
|
||||||
logger.critical('No output path specified. Use -o argument or specify output path in configuration file')
|
logger.critical(
|
||||||
|
'No output path specified. Use -o argument or specify output path in configuration file')
|
||||||
|
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
@@ -181,7 +183,8 @@ class Backup:
|
|||||||
if self._ssh is None:
|
if self._ssh is None:
|
||||||
return 5
|
return 5
|
||||||
|
|
||||||
_, stdout, _ = self._ssh.exec_command(f'if [ -d "{self.output}" ]; then echo "ok"; fi')
|
_, stdout, _ = self._ssh.exec_command(
|
||||||
|
f'if [ -d "{self.output}" ]; then echo "ok"; fi')
|
||||||
|
|
||||||
output = stdout.read().decode('utf-8').strip()
|
output = stdout.read().decode('utf-8').strip()
|
||||||
|
|
||||||
@@ -205,7 +208,9 @@ class Backup:
|
|||||||
# Function to create the actual backup directory
|
# Function to create the actual backup directory
|
||||||
def define_backup_dir(self) -> None:
|
def define_backup_dir(self) -> None:
|
||||||
"""Define the actual backup dir"""
|
"""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}'
|
self._output_dir = f'{self.output}/simple_backup/{now}'
|
||||||
|
|
||||||
if self._remote:
|
if self._remote:
|
||||||
@@ -217,7 +222,8 @@ class Backup:
|
|||||||
if self._remote:
|
if self._remote:
|
||||||
assert self._ssh is not None
|
assert self._ssh is not None
|
||||||
|
|
||||||
_, stdout, _ = self._ssh.exec_command(f'ls {self.output}/simple_backup')
|
_, stdout, _ = self._ssh.exec_command(
|
||||||
|
f'ls {self.output}/simple_backup')
|
||||||
|
|
||||||
dirs = stdout.read().decode('utf-8').strip().split('\n')
|
dirs = stdout.read().decode('utf-8').strip().split('\n')
|
||||||
|
|
||||||
@@ -234,14 +240,17 @@ class Backup:
|
|||||||
|
|
||||||
for i in range(n_backup - self.keep):
|
for i in range(n_backup - self.keep):
|
||||||
if self.remote_sudo:
|
if self.remote_sudo:
|
||||||
_, _, stderr = self._ssh.exec_command(f'sudo rm -r "{self.output}/simple_backup/{dirs[i]}"')
|
_, _, stderr = self._ssh.exec_command(
|
||||||
|
f'sudo rm -r "{self.output}/simple_backup/{dirs[i]}"')
|
||||||
else:
|
else:
|
||||||
_, _, stderr = self._ssh.exec_command(f'rm -r "{self.output}/simple_backup/{dirs[i]}"')
|
_, _, stderr = self._ssh.exec_command(
|
||||||
|
f'rm -r "{self.output}/simple_backup/{dirs[i]}"')
|
||||||
|
|
||||||
err = stderr.read().decode('utf-8').strip().split('\n')[0]
|
err = stderr.read().decode('utf-8').strip().split('\n')[0]
|
||||||
|
|
||||||
if err != '':
|
if err != '':
|
||||||
logger.error('Error while removing backup %s.', {dirs[i]})
|
logger.error(
|
||||||
|
'Error while removing backup %s.', {dirs[i]})
|
||||||
logger.error(err)
|
logger.error(err)
|
||||||
else:
|
else:
|
||||||
count += 1
|
count += 1
|
||||||
@@ -267,9 +276,13 @@ class Backup:
|
|||||||
rmtree(f'{self.output}/simple_backup/{dirs[i]}')
|
rmtree(f'{self.output}/simple_backup/{dirs[i]}')
|
||||||
count += 1
|
count += 1
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
logger.error('Error while removing backup %s. Directory not found', dirs[i])
|
logger.error(
|
||||||
|
'Error while removing backup %s. Directory not found', dirs[i])
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
logger.error('Error while removing backup %s. Permission denied', dirs[i])
|
logger.error(
|
||||||
|
'Error while removing backup %s. Permission denied', dirs[i])
|
||||||
|
|
||||||
|
self._removed_count = count
|
||||||
|
|
||||||
if count == 1:
|
if count == 1:
|
||||||
logger.info('Removed %d backup', count)
|
logger.info('Removed %d backup', count)
|
||||||
@@ -284,7 +297,8 @@ class Backup:
|
|||||||
logger.critical('SSH connection to server failed')
|
logger.critical('SSH connection to server failed')
|
||||||
sys.exit(5)
|
sys.exit(5)
|
||||||
|
|
||||||
_, stdout, _ = self._ssh.exec_command(f'find {self.output}/simple_backup/ -mindepth 1 -maxdepth 1 -type d | sort')
|
_, stdout, _ = self._ssh.exec_command(
|
||||||
|
f'find {self.output}/simple_backup/ -mindepth 1 -maxdepth 1 -type d | sort')
|
||||||
output = stdout.read().decode('utf-8').strip().split('\n')
|
output = stdout.read().decode('utf-8').strip().split('\n')
|
||||||
|
|
||||||
if output[-1] != '':
|
if output[-1] != '':
|
||||||
@@ -293,18 +307,17 @@ class Backup:
|
|||||||
logger.info('No previous backups available')
|
logger.info('No previous backups available')
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
dirs = sorted([f.path for f in os.scandir(f'{self.output}/simple_backup') if f.is_dir(follow_symlinks=False)])
|
dirs = sorted([f.path for f in os.scandir(
|
||||||
|
f'{self.output}/simple_backup') if f.is_dir(follow_symlinks=False)])
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
logger.info('No previous backups available')
|
logger.info('No previous backups available')
|
||||||
|
|
||||||
return
|
return
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
logger.critical('Cannot access the backup directory. Permission denied')
|
logger.critical(
|
||||||
|
'Cannot access the backup directory. Permission denied')
|
||||||
|
|
||||||
try:
|
|
||||||
_notify('Backup failed (check log for details)')
|
_notify('Backup failed (check log for details)')
|
||||||
except NameError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
sys.exit(3)
|
sys.exit(3)
|
||||||
|
|
||||||
@@ -313,7 +326,7 @@ class Backup:
|
|||||||
except IndexError:
|
except IndexError:
|
||||||
logger.info('No previous backups available')
|
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:
|
try:
|
||||||
ssh = paramiko.SSHClient()
|
ssh = paramiko.SSHClient()
|
||||||
except NameError:
|
except NameError:
|
||||||
@@ -329,11 +342,15 @@ class Backup:
|
|||||||
ssh.set_missing_host_key_policy(paramiko.WarningPolicy())
|
ssh.set_missing_host_key_policy(paramiko.WarningPolicy())
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# 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)
|
ssh.connect(self.ssh_host, username=self.ssh_user)
|
||||||
|
|
||||||
return ssh
|
return ssh
|
||||||
except UserWarning:
|
except UserWarning:
|
||||||
k = input(f'Unknown key for host {self.ssh_host}. Continue anyway? (Y/N) ')
|
k = input(
|
||||||
|
f'Unknown key for host {self.ssh_host}. Continue anyway? (Y/N) ')
|
||||||
|
|
||||||
if k[0].upper() == 'Y':
|
if k[0].upper() == 'Y':
|
||||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
@@ -356,8 +373,14 @@ class Backup:
|
|||||||
|
|
||||||
if self.ssh_keyfile is None:
|
if self.ssh_keyfile is None:
|
||||||
try:
|
try:
|
||||||
password = getpass(f'{self.ssh_user}@{self.ssh_host}\'s password: ')
|
# Fail instead of reading the password with echo if no terminal is available
|
||||||
ssh.connect(self.ssh_host, username=self.ssh_user, password=password)
|
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,
|
||||||
|
password=password)
|
||||||
|
|
||||||
self._password_auth = True
|
self._password_auth = True
|
||||||
os.environ['SSHPASS'] = password
|
os.environ['SSHPASS'] = password
|
||||||
@@ -379,7 +402,8 @@ class Backup:
|
|||||||
try:
|
try:
|
||||||
pkey = RSAKey.from_private_key_file(self.ssh_keyfile)
|
pkey = RSAKey.from_private_key_file(self.ssh_keyfile)
|
||||||
except paramiko.PasswordRequiredException:
|
except paramiko.PasswordRequiredException:
|
||||||
password = getpass(f'Enter passwphrase for key \'{self.ssh_keyfile}\': ')
|
password = getpass(
|
||||||
|
f'Enter passwphrase for key \'{self.ssh_keyfile}\': ')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
pkey = RSAKey.from_private_key_file(self.ssh_keyfile, password)
|
pkey = RSAKey.from_private_key_file(self.ssh_keyfile, password)
|
||||||
@@ -391,7 +415,8 @@ class Backup:
|
|||||||
pkey = Ed25519Key.from_private_key_file(self.ssh_keyfile)
|
pkey = Ed25519Key.from_private_key_file(self.ssh_keyfile)
|
||||||
except paramiko.PasswordRequiredException:
|
except paramiko.PasswordRequiredException:
|
||||||
try:
|
try:
|
||||||
pkey = Ed25519Key.from_private_key_file(self.ssh_keyfile, password)
|
pkey = Ed25519Key.from_private_key_file(
|
||||||
|
self.ssh_keyfile, password)
|
||||||
except paramiko.SSHException:
|
except paramiko.SSHException:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -400,7 +425,8 @@ class Backup:
|
|||||||
pkey = ECDSAKey.from_private_key_file(self.ssh_keyfile)
|
pkey = ECDSAKey.from_private_key_file(self.ssh_keyfile)
|
||||||
except paramiko.PasswordRequiredException:
|
except paramiko.PasswordRequiredException:
|
||||||
try:
|
try:
|
||||||
pkey = ECDSAKey.from_private_key_file(self.ssh_keyfile, password)
|
pkey = ECDSAKey.from_private_key_file(
|
||||||
|
self.ssh_keyfile, password)
|
||||||
except paramiko.SSHException:
|
except paramiko.SSHException:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -409,7 +435,8 @@ class Backup:
|
|||||||
pkey = DSSKey.from_private_key_file(self.ssh_keyfile)
|
pkey = DSSKey.from_private_key_file(self.ssh_keyfile)
|
||||||
except paramiko.PasswordRequiredException:
|
except paramiko.PasswordRequiredException:
|
||||||
try:
|
try:
|
||||||
pkey = DSSKey.from_private_key_file(self.ssh_keyfile, password)
|
pkey = DSSKey.from_private_key_file(
|
||||||
|
self.ssh_keyfile, password)
|
||||||
except paramiko.SSHException:
|
except paramiko.SSHException:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -425,72 +452,94 @@ class Backup:
|
|||||||
def _returncode_log(self, returncode: int) -> None:
|
def _returncode_log(self, returncode: int) -> None:
|
||||||
match returncode:
|
match returncode:
|
||||||
case 2:
|
case 2:
|
||||||
logger.error('Rsync error (return code 2) - Protocol incompatibility')
|
logger.error(
|
||||||
|
'Rsync error (return code 2) - Protocol incompatibility')
|
||||||
case 3:
|
case 3:
|
||||||
logger.error('Rsync error (return code 3) - Errors selecting input/output files, dirs')
|
logger.error(
|
||||||
|
'Rsync error (return code 3) - Errors selecting input/output files, dirs')
|
||||||
case 4:
|
case 4:
|
||||||
logger.error('Rsync error (return code 4) - Requested action not supported')
|
logger.error(
|
||||||
|
'Rsync error (return code 4) - Requested action not supported')
|
||||||
case 5:
|
case 5:
|
||||||
logger.error('Rsync error (return code 5) - Error starting client-server protocol')
|
logger.error(
|
||||||
|
'Rsync error (return code 5) - Error starting client-server protocol')
|
||||||
case 10:
|
case 10:
|
||||||
logger.error('Rsync error (return code 10) - Error in socket I/O')
|
logger.error(
|
||||||
|
'Rsync error (return code 10) - Error in socket I/O')
|
||||||
case 11:
|
case 11:
|
||||||
logger.error('Rsync error (return code 11) - Error in file I/O')
|
logger.error(
|
||||||
|
'Rsync error (return code 11) - Error in file I/O')
|
||||||
case 12:
|
case 12:
|
||||||
logger.error('Rsync error (return code 12) - Error in rsync protocol data stream')
|
logger.error(
|
||||||
|
'Rsync error (return code 12) - Error in rsync protocol data stream')
|
||||||
case 22:
|
case 22:
|
||||||
logger.error('Rsync error (return code 22) - Error allocating core memory buffers')
|
logger.error(
|
||||||
|
'Rsync error (return code 22) - Error allocating core memory buffers')
|
||||||
case 23:
|
case 23:
|
||||||
logger.warning('Rsync error (return code 23) - Partial transfer due to error')
|
logger.warning(
|
||||||
|
'Rsync error (return code 23) - Partial transfer due to error')
|
||||||
case 24:
|
case 24:
|
||||||
logger.warning('Rsync error (return code 24) - Partial transfer due to vanished source files')
|
logger.warning(
|
||||||
|
'Rsync error (return code 24) - Partial transfer due to vanished source files')
|
||||||
case 30:
|
case 30:
|
||||||
logger.error('Rsync error (return code 30) - Timeout in data send/receive')
|
logger.error(
|
||||||
|
'Rsync error (return code 30) - Timeout in data send/receive')
|
||||||
case 35:
|
case 35:
|
||||||
logger.error('Rsync error (return code 35) - Timeout waiting for daemon connection')
|
logger.error(
|
||||||
|
'Rsync error (return code 35) - Timeout waiting for daemon connection')
|
||||||
case _:
|
case _:
|
||||||
logger.error('Rsync error (return code %d) - Check rsync(1) for details', returncode)
|
logger.error(
|
||||||
|
'Rsync error (return code %d) - Check rsync(1) for details', returncode)
|
||||||
|
|
||||||
# Function to read configuration file
|
# Function to read configuration file
|
||||||
@timing
|
@timing
|
||||||
def run(self) -> int:
|
def run(self) -> int:
|
||||||
"""Perform the backup"""
|
"""Perform the backup"""
|
||||||
|
|
||||||
logger.info('Starting backup...')
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
_notify('Starting backup...')
|
return self._run()
|
||||||
except NameError:
|
finally:
|
||||||
|
self._remove_temp_files()
|
||||||
|
|
||||||
|
def _remove_temp_files(self) -> None:
|
||||||
|
for path in [self._inputs_path, self._exclude_path]:
|
||||||
|
if path != '':
|
||||||
|
try:
|
||||||
|
os.remove(path)
|
||||||
|
except FileNotFoundError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
self.define_backup_dir()
|
def _run(self) -> int:
|
||||||
self.find_last_backup()
|
logger.info('Starting backup...')
|
||||||
|
|
||||||
_, self._inputs_path = mkstemp(prefix='tmp_inputs', text=True)
|
_notify('Starting backup...')
|
||||||
|
|
||||||
|
self.define_backup_dir()
|
||||||
|
|
||||||
|
fd, self._inputs_path = mkstemp(prefix='tmp_inputs', text=True)
|
||||||
count = 0
|
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:
|
for i in self.inputs:
|
||||||
if not os.path.exists(i):
|
if not os.path.exists(i):
|
||||||
logger.warning('Input %s not found. Skipping', i)
|
logger.warning('Input %s not found. Skipping', i)
|
||||||
else:
|
else:
|
||||||
fp.write(i)
|
# rsync reads --files-from paths relative to the source ('/')
|
||||||
|
fp.write(os.path.abspath(i))
|
||||||
fp.write('\n')
|
fp.write('\n')
|
||||||
count += 1
|
count += 1
|
||||||
|
|
||||||
if count == 0:
|
if count == 0:
|
||||||
logger.info('No existing files or directories specified for backup. Nothing to do')
|
logger.info(
|
||||||
|
'No existing files or directories specified for backup. Nothing to do')
|
||||||
|
|
||||||
try:
|
|
||||||
_notify('Backup finished. No files copied')
|
_notify('Backup finished. No files copied')
|
||||||
except NameError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return 1
|
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:
|
if self.exclude is not None:
|
||||||
for e in self.exclude:
|
for e in self.exclude:
|
||||||
fp.write(e)
|
fp.write(e)
|
||||||
@@ -499,6 +548,9 @@ class Backup:
|
|||||||
if self.keep != -1 and self._remove_before:
|
if self.keep != -1 and self._remove_before:
|
||||||
self.remove_old_backups()
|
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...')
|
logger.info('Copying files. This may take a long time...')
|
||||||
|
|
||||||
if self._last_backup == '':
|
if self._last_backup == '':
|
||||||
@@ -550,92 +602,106 @@ class Backup:
|
|||||||
else:
|
else:
|
||||||
logger.error(output)
|
logger.error(output)
|
||||||
|
|
||||||
if self.keep != -1 and not self._remove_before:
|
if self._removed_count > 0 and returncode not in [0, 24]:
|
||||||
self.remove_old_backups()
|
# 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:
|
||||||
|
message = 'Old backups were removed before the backup, and the new backup may be incomplete'
|
||||||
|
|
||||||
os.remove(self._inputs_path)
|
logger.error(message)
|
||||||
os.remove(self._exclude_path)
|
_notify(message)
|
||||||
|
|
||||||
|
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')
|
||||||
|
|
||||||
if self._remote:
|
if self._remote:
|
||||||
assert self._ssh is not None
|
assert self._ssh is not None
|
||||||
|
|
||||||
_, stdout, _ = self._ssh.exec_command(f'if [ -d "{self._output_dir}" ]; then echo "ok"; fi')
|
_, stdout, _ = self._ssh.exec_command(
|
||||||
|
f'if [ -d "{self._output_dir}" ]; then echo "ok"; fi')
|
||||||
|
|
||||||
output = stdout.read().decode('utf-8').strip()
|
output = stdout.read().decode('utf-8').strip()
|
||||||
|
|
||||||
if output == 'ok':
|
if output == 'ok':
|
||||||
logger.info('Backup completed')
|
logger.info('Backup completed')
|
||||||
|
|
||||||
try:
|
|
||||||
_notify('Backup completed')
|
_notify('Backup completed')
|
||||||
except NameError:
|
|
||||||
pass
|
|
||||||
else:
|
else:
|
||||||
logger.error('Backup failed')
|
logger.error('Backup failed')
|
||||||
|
|
||||||
try:
|
|
||||||
_notify('Backup failed (check log for details)')
|
_notify('Backup failed (check log for details)')
|
||||||
except NameError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if self._ssh:
|
if self._ssh:
|
||||||
self._ssh.close()
|
self._ssh.close()
|
||||||
else:
|
else:
|
||||||
if returncode != 0:
|
if returncode != 0:
|
||||||
logger.error('Some errors occurred while performing the backup')
|
logger.error(
|
||||||
|
'Some errors occurred while performing the backup')
|
||||||
|
|
||||||
try:
|
_notify(
|
||||||
_notify('Some errors occurred while performing the backup. Check log for details')
|
'Some errors occurred while performing the backup. Check log for details')
|
||||||
except NameError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return 4
|
return 4
|
||||||
|
|
||||||
logger.info('Backup completed')
|
logger.info('Backup completed')
|
||||||
|
|
||||||
try:
|
|
||||||
_notify('Backup completed')
|
_notify('Backup completed')
|
||||||
except NameError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def _parse_arguments():
|
def _detect_user() -> Optional[str]:
|
||||||
euid = os.geteuid()
|
if os.geteuid() == 0:
|
||||||
|
return os.getenv('SUDO_USER') or os.getenv('DOAS_USER')
|
||||||
|
|
||||||
if euid == 0:
|
return os.getenv('USER')
|
||||||
user = os.getenv('SUDO_USER')
|
|
||||||
else:
|
|
||||||
user = os.getenv('USER')
|
|
||||||
|
|
||||||
homedir = os.path.expanduser(f'~{user}')
|
|
||||||
|
|
||||||
|
def _parse_arguments() -> argparse.Namespace:
|
||||||
parser = argparse.ArgumentParser(prog='simple_backup',
|
parser = argparse.ArgumentParser(prog='simple_backup',
|
||||||
description='Simple backup script written in Python that uses rsync to copy files',
|
description='Simple backup script written in Python that uses rsync to copy files',
|
||||||
epilog='See simple_backup(1) manpage for full documentation',
|
epilog='See simple_backup(1) manpage for full documentation',
|
||||||
formatter_class=MyFormatter)
|
formatter_class=MyFormatter)
|
||||||
|
|
||||||
parser.add_argument('-v', '--verbose', action='store_true', help='More verbose output')
|
parser.add_argument('-v', '--verbose', action='store_true',
|
||||||
parser.add_argument('-c', '--config', default=f'{homedir}/.config/simple_backup/simple_backup.conf',
|
help='More verbose output')
|
||||||
|
parser.add_argument('-c', '--config', default='~/.config/simple_backup/simple_backup.conf',
|
||||||
help='Specify location of configuration file')
|
help='Specify location of configuration file')
|
||||||
parser.add_argument('-i', '--inputs', nargs='+', help='Paths/files to backup')
|
parser.add_argument('-i', '--inputs', nargs='+',
|
||||||
parser.add_argument('-o', '--output', help='Output directory for the backup')
|
help='Paths/files to backup')
|
||||||
parser.add_argument('-e', '--exclude', nargs='+', help='Files/directories/patterns to exclude from the backup')
|
parser.add_argument(
|
||||||
parser.add_argument('-k', '--keep', type=int, help='Number of old backups to keep')
|
'-o', '--output', help='Output directory for the backup')
|
||||||
parser.add_argument('-u', '--user', help='Explicitly specify the user running the backup')
|
parser.add_argument('-e', '--exclude', nargs='+',
|
||||||
parser.add_argument('-s', '--checksum', action='store_true', help='Use checksum rsync option to compare files')
|
help='Files/directories/patterns to exclude from the backup')
|
||||||
parser.add_argument('--ssh-host', help='Server hostname (for remote backup)')
|
parser.add_argument('-k', '--keep', type=int,
|
||||||
parser.add_argument('--ssh-user', help='Username to connect to server (for remote backup)')
|
help='Number of old backups to keep')
|
||||||
|
parser.add_argument(
|
||||||
|
'-u', '--user', help='Explicitly specify the user running the backup')
|
||||||
|
parser.add_argument('-s', '--checksum', action='store_true',
|
||||||
|
help='Use checksum rsync option to compare files')
|
||||||
|
parser.add_argument(
|
||||||
|
'--ssh-host', help='Server hostname (for remote backup)')
|
||||||
|
parser.add_argument(
|
||||||
|
'--ssh-user', help='Username to connect to server (for remote backup)')
|
||||||
parser.add_argument('--keyfile', help='SSH key location')
|
parser.add_argument('--keyfile', help='SSH key location')
|
||||||
parser.add_argument('-z', '--compress', action='store_true', help='Compress data during the transfer')
|
parser.add_argument('-z', '--compress', action='store_true',
|
||||||
|
help='Compress data during the transfer')
|
||||||
parser.add_argument('--remove-before-backup', action='store_true',
|
parser.add_argument('--remove-before-backup', action='store_true',
|
||||||
help='Remove old backups before executing the backup, instead of after')
|
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('--no-syslog', action='store_true',
|
||||||
|
help='Disable systemd journal logging')
|
||||||
parser.add_argument('--rsync-options', nargs='+',
|
parser.add_argument('--rsync-options', nargs='+',
|
||||||
choices=['a', 'l', 'p', 't', 'g', 'o', 'c', 'h', 's', 'D', 'H', 'X'],
|
choices=['a', 'l', 'p', 't', 'g', 'o',
|
||||||
|
'c', 'h', 's', 'D', 'H', 'X'],
|
||||||
help='Specify options for rsync')
|
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('--remote-sudo', action='store_true',
|
||||||
|
help='Run rsync on remote server with sudo if allowed')
|
||||||
parser.add_argument('--numeric-ids', action='store_true',
|
parser.add_argument('--numeric-ids', action='store_true',
|
||||||
help='Use rsync \'--numeric-ids\' option (don\'t map uid/gid values by name)')
|
help='Use rsync \'--numeric-ids\' option (don\'t map uid/gid values by name)')
|
||||||
|
|
||||||
@@ -644,7 +710,16 @@ def _parse_arguments():
|
|||||||
return args
|
return args
|
||||||
|
|
||||||
|
|
||||||
def _expand_inputs(inputs, user=None):
|
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 = []
|
expanded_inputs = []
|
||||||
|
|
||||||
for i in inputs:
|
for i in inputs:
|
||||||
@@ -652,22 +727,23 @@ def _expand_inputs(inputs, user=None):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
if user is not None:
|
if user is not None:
|
||||||
i_ex = glob(os.path.expanduser(i.replace('~', f'~{user}')))
|
i_ex = glob(_expand_home(i, user))
|
||||||
else:
|
else:
|
||||||
i_ex = glob(i)
|
i_ex = glob(i)
|
||||||
|
|
||||||
if '~' in i:
|
if i.startswith('~'):
|
||||||
logger.warning('Cannot expand \'~\'. No user specified')
|
logger.warning('Cannot expand \'~\'. No user specified')
|
||||||
|
|
||||||
if len(i_ex) == 0:
|
if len(i_ex) == 0:
|
||||||
logger.warning('No file or directory matching input %s. Skipping...', i)
|
logger.warning(
|
||||||
|
'No file or directory matching input %s. Skipping...', i)
|
||||||
else:
|
else:
|
||||||
expanded_inputs.extend(i_ex)
|
expanded_inputs.extend(i_ex)
|
||||||
|
|
||||||
return expanded_inputs
|
return expanded_inputs
|
||||||
|
|
||||||
|
|
||||||
def _read_config(config_file, user=None):
|
def _read_config(config_file, user: Optional[str] = None):
|
||||||
config_args = {'inputs': None,
|
config_args = {'inputs': None,
|
||||||
'output': None,
|
'output': None,
|
||||||
'exclude': None,
|
'exclude': None,
|
||||||
@@ -679,11 +755,16 @@ def _read_config(config_file, user=None):
|
|||||||
'numeric_ids': False}
|
'numeric_ids': False}
|
||||||
|
|
||||||
if not os.path.isfile(config_file):
|
if not os.path.isfile(config_file):
|
||||||
|
if user is not None:
|
||||||
logger.warning('Config file %s does not exist', config_file)
|
logger.warning('Config file %s does not exist', config_file)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
'User not specified. Can\'t read configuration file')
|
||||||
|
|
||||||
return config_args
|
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)
|
config.read(config_file)
|
||||||
|
|
||||||
section = 'backup'
|
section = 'backup'
|
||||||
@@ -695,7 +776,7 @@ def _read_config(config_file, user=None):
|
|||||||
section = 'default'
|
section = 'default'
|
||||||
inputs = config.get(section, 'inputs')
|
inputs = config.get(section, 'inputs')
|
||||||
|
|
||||||
inputs = inputs.split(',')
|
inputs = [i.strip() for i in inputs.split(',')]
|
||||||
inputs = _expand_inputs(inputs, user)
|
inputs = _expand_inputs(inputs, user)
|
||||||
inputs = list(set(inputs))
|
inputs = list(set(inputs))
|
||||||
|
|
||||||
@@ -704,15 +785,15 @@ def _read_config(config_file, user=None):
|
|||||||
output = config.get(section, 'backup_dir')
|
output = config.get(section, 'backup_dir')
|
||||||
|
|
||||||
if user is not None:
|
if user is not None:
|
||||||
output = os.path.expanduser(output.replace('~', f'~{user}'))
|
output = _expand_home(output, user)
|
||||||
elif user is None and '~' in output:
|
elif user is None and output.startswith('~'):
|
||||||
logger.warning('Cannot expand \'~\', no user specified')
|
logger.warning('Cannot expand \'~\', no user specified')
|
||||||
|
|
||||||
config_args['output'] = output
|
config_args['output'] = output
|
||||||
|
|
||||||
try:
|
try:
|
||||||
exclude = config.get(section, 'exclude')
|
exclude = config.get(section, 'exclude')
|
||||||
exclude = exclude.split(',')
|
exclude = [e.strip() for e in exclude.split(',') if e.strip() != '']
|
||||||
except configparser.NoOptionError:
|
except configparser.NoOptionError:
|
||||||
exclude = []
|
exclude = []
|
||||||
|
|
||||||
@@ -759,28 +840,59 @@ def _read_config(config_file, user=None):
|
|||||||
return config_args
|
return config_args
|
||||||
|
|
||||||
|
|
||||||
def _notify(text):
|
def _target_uid() -> int | None:
|
||||||
euid = os.geteuid()
|
euid = os.geteuid()
|
||||||
|
|
||||||
if euid == 0:
|
if euid != 0:
|
||||||
uid = os.getenv('SUDO_UID')
|
return euid
|
||||||
else:
|
|
||||||
uid = euid
|
|
||||||
|
|
||||||
if uid is None:
|
if (val := os.getenv('SUDO_UID')) is not None:
|
||||||
|
return int(val)
|
||||||
|
|
||||||
|
if (name := os.getenv('DOAS_USER')) is not None:
|
||||||
|
try:
|
||||||
|
return pwd.getpwnam(name).pw_uid
|
||||||
|
except KeyError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
_notify_failed = False
|
||||||
|
|
||||||
|
|
||||||
|
def _notify(text: str) -> None:
|
||||||
|
global _notify_failed
|
||||||
|
|
||||||
|
if dbus is None or _notify_failed:
|
||||||
return
|
return
|
||||||
|
|
||||||
os.seteuid(int(uid))
|
euid = os.geteuid()
|
||||||
|
uid = _target_uid()
|
||||||
|
|
||||||
|
if uid is None:
|
||||||
|
logger.debug('Cannot send desktop notification: user not found')
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
os.seteuid(uid)
|
||||||
|
|
||||||
|
try:
|
||||||
os.environ['DBUS_SESSION_BUS_ADDRESS'] = f'unix:path=/run/user/{uid}/bus'
|
os.environ['DBUS_SESSION_BUS_ADDRESS'] = f'unix:path=/run/user/{uid}/bus'
|
||||||
|
|
||||||
obj = dbus.SessionBus().get_object('org.freedesktop.Notifications', '/org/freedesktop/Notifications')
|
obj = dbus.SessionBus().get_object('org.freedesktop.Notifications',
|
||||||
|
'/org/freedesktop/Notifications')
|
||||||
obj = dbus.Interface(obj, 'org.freedesktop.Notifications')
|
obj = dbus.Interface(obj, 'org.freedesktop.Notifications')
|
||||||
obj.Notify('', 0, '', 'simple_backup', text, [], {'urgency': 1}, 10000)
|
obj.Notify('simple_backup', 0, '', 'simple_backup', text, [], {'urgency': dbus.Byte(1)}, 10000)
|
||||||
|
except Exception as e:
|
||||||
os.seteuid(int(euid))
|
# 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)
|
||||||
|
|
||||||
|
|
||||||
def simple_backup():
|
def simple_backup() -> int:
|
||||||
"""Main"""
|
"""Main"""
|
||||||
|
|
||||||
args = _parse_arguments()
|
args = _parse_arguments()
|
||||||
@@ -792,8 +904,14 @@ def simple_backup():
|
|||||||
euid = os.geteuid()
|
euid = os.geteuid()
|
||||||
|
|
||||||
if euid == 0:
|
if euid == 0:
|
||||||
user = os.getenv('SUDO_USER')
|
user = _detect_user()
|
||||||
|
|
||||||
|
if user is not None:
|
||||||
homedir = os.path.expanduser(f'~{user}')
|
homedir = os.path.expanduser(f'~{user}')
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
'Failed to detect user. You can use -u/--user parameter to manually specify it')
|
||||||
|
homedir = None
|
||||||
else:
|
else:
|
||||||
user = os.getenv('USER')
|
user = os.getenv('USER')
|
||||||
homedir = os.getenv('HOME')
|
homedir = os.getenv('HOME')
|
||||||
@@ -807,10 +925,19 @@ def simple_backup():
|
|||||||
except NameError:
|
except NameError:
|
||||||
pass
|
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:
|
try:
|
||||||
config_args = _read_config(args.config, user)
|
config_args = _read_config(config_file, user)
|
||||||
except (configparser.NoSectionError, configparser.NoOptionError):
|
except (configparser.Error, ValueError) as e:
|
||||||
logger.critical('Bad configuration file')
|
# configparser.Error: missing sections/options or bad syntax, ValueError: invalid values (e.g. keep=abc)
|
||||||
|
logger.critical('Bad configuration file: %s', e)
|
||||||
|
|
||||||
return 6
|
return 6
|
||||||
|
|
||||||
@@ -823,11 +950,12 @@ def simple_backup():
|
|||||||
ssh_keyfile = args.keyfile if args.keyfile is not None else config_args['ssh_keyfile']
|
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']
|
remote_sudo = args.remote_sudo or config_args['remote_sudo']
|
||||||
|
|
||||||
if args.rsync_options is None:
|
# Options needed in any case (--mkpath creates the backup directory on the first run)
|
||||||
rsync_options = ['-a', '-r', '-v', '-h', '-H', '-X', '-s', '--ignore-missing-args', '--mkpath']
|
rsync_options = ['-r', '-v', '--ignore-missing-args', '--mkpath']
|
||||||
else:
|
|
||||||
rsync_options = ['-r', '-v']
|
|
||||||
|
|
||||||
|
if args.rsync_options is None:
|
||||||
|
rsync_options.extend(['-a', '-h', '-H', '-X', '-s'])
|
||||||
|
else:
|
||||||
for ro in args.rsync_options:
|
for ro in args.rsync_options:
|
||||||
rsync_options.append(f'-{ro}')
|
rsync_options.append(f'-{ro}')
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user