Find rsync in PATH and pass the SSH password only to rsync
A missing /usr/bin/rsync ended in a traceback, possibly after --remove-before-backup had removed old backups, and left SSHPASS in the environment.
This commit is contained in:
+1
-1
@@ -209,7 +209,7 @@ Backup failed because output directory for storing the backup does not exist.
|
|||||||
Permission denied to access the output directory.
|
Permission denied to access the output directory.
|
||||||
.TP
|
.TP
|
||||||
.B 4
|
.B 4
|
||||||
rsync error (rsync returned a value other than 0 or 24).
|
rsync error (rsync returned a value other than 0 or 24, or rsync is not installed).
|
||||||
.TP
|
.TP
|
||||||
.B 5
|
.B 5
|
||||||
SSH connection failed.
|
SSH connection failed.
|
||||||
|
|||||||
@@ -229,7 +229,8 @@ class Backup:
|
|||||||
self._exclude_path = ''
|
self._exclude_path = ''
|
||||||
self._remote = False
|
self._remote = False
|
||||||
self._ssh: paramiko.client.SSHClient | None = None
|
self._ssh: paramiko.client.SSHClient | None = None
|
||||||
self._password_auth = False
|
# Passed to sshpass through rsync's environment only
|
||||||
|
self._password: str | None = None
|
||||||
self._removed_count = 0
|
self._removed_count = 0
|
||||||
|
|
||||||
def check_params(self, homedir: str = '') -> int:
|
def check_params(self, homedir: str = '') -> int:
|
||||||
@@ -558,8 +559,7 @@ class Backup:
|
|||||||
|
|
||||||
ssh.connect(**host, username=self.ssh_user, timeout=SSH_TIMEOUT, password=password)
|
ssh.connect(**host, username=self.ssh_user, timeout=SSH_TIMEOUT, password=password)
|
||||||
|
|
||||||
self._password_auth = True
|
self._password = password
|
||||||
os.environ['SSHPASS'] = password
|
|
||||||
|
|
||||||
return ssh
|
return ssh
|
||||||
except GetPassWarning as e:
|
except GetPassWarning as e:
|
||||||
@@ -690,6 +690,16 @@ class Backup:
|
|||||||
|
|
||||||
_notify('Starting backup...')
|
_notify('Starting backup...')
|
||||||
|
|
||||||
|
# Check before anything is removed (--remove-before-backup)
|
||||||
|
rsync = which('rsync')
|
||||||
|
|
||||||
|
if rsync is None:
|
||||||
|
logger.critical('rsync not found. Install rsync to perform the backup')
|
||||||
|
|
||||||
|
_notify('Backup failed (check log for details)')
|
||||||
|
|
||||||
|
return 4
|
||||||
|
|
||||||
self.define_backup_dir()
|
self.define_backup_dir()
|
||||||
|
|
||||||
fd, self._inputs_path = mkstemp(prefix='tmp_inputs', text=True)
|
fd, self._inputs_path = mkstemp(prefix='tmp_inputs', text=True)
|
||||||
@@ -729,7 +739,7 @@ class Backup:
|
|||||||
logger.info('Copying files. This may take a long time...')
|
logger.info('Copying files. This may take a long time...')
|
||||||
|
|
||||||
# Build the argument list directly, so paths with spaces or quotes are passed unchanged
|
# Build the argument list directly, so paths with spaces or quotes are passed unchanged
|
||||||
args = ['/usr/bin/rsync', *self.options.split()]
|
args = [rsync, *self.options.split()]
|
||||||
|
|
||||||
if self._last_backup != '':
|
if self._last_backup != '':
|
||||||
args.append(f'--link-dest={self._last_backup}')
|
args.append(f'--link-dest={self._last_backup}')
|
||||||
@@ -737,11 +747,15 @@ class Backup:
|
|||||||
args.extend([f'--exclude-from={self._exclude_path}', f'--files-from={self._inputs_path}',
|
args.extend([f'--exclude-from={self._exclude_path}', f'--files-from={self._inputs_path}',
|
||||||
'/', f'{self._server}{self._output_dir}'])
|
'/', f'{self._server}{self._output_dir}'])
|
||||||
|
|
||||||
|
# None inherits our environment
|
||||||
|
env: dict[str, str] | None = None
|
||||||
|
|
||||||
# rsync splits the -e command itself, so quote the parts that may contain spaces
|
# rsync splits the -e command itself, so quote the parts that may contain spaces
|
||||||
if self.ssh_keyfile is not None:
|
if self.ssh_keyfile is not None:
|
||||||
ssh = f'ssh -i {shlex.quote(self.ssh_keyfile)} -o StrictHostKeyChecking=accept-new'
|
ssh = f'ssh -i {shlex.quote(self.ssh_keyfile)} -o StrictHostKeyChecking=accept-new'
|
||||||
elif self._password_auth and self.ssh_user is not None and which('sshpass'):
|
elif self._password is not None and self.ssh_user is not None and which('sshpass'):
|
||||||
ssh = f'sshpass -e ssh -l {shlex.quote(self.ssh_user)} -o StrictHostKeyChecking=accept-new'
|
ssh = f'sshpass -e ssh -l {shlex.quote(self.ssh_user)} -o StrictHostKeyChecking=accept-new'
|
||||||
|
env = {**os.environ, 'SSHPASS': self._password}
|
||||||
else:
|
else:
|
||||||
ssh = 'ssh -o StrictHostKeyChecking=accept-new'
|
ssh = 'ssh -o StrictHostKeyChecking=accept-new'
|
||||||
|
|
||||||
@@ -756,7 +770,7 @@ class Backup:
|
|||||||
args.append('--info=progress2')
|
args.append('--info=progress2')
|
||||||
bar = _ProgressBar()
|
bar = _ProgressBar()
|
||||||
|
|
||||||
with Popen(args, stdin=PIPE, stdout=PIPE, stderr=STDOUT, shell=False) as p:
|
with Popen(args, stdin=PIPE, stdout=PIPE, stderr=STDOUT, shell=False, env=env) as p:
|
||||||
assert p.stdin is not None and isinstance(p.stdout, io.BufferedIOBase)
|
assert p.stdin is not None and isinstance(p.stdout, io.BufferedIOBase)
|
||||||
p.stdin.close()
|
p.stdin.close()
|
||||||
|
|
||||||
@@ -770,11 +784,6 @@ class Backup:
|
|||||||
if bar is not None:
|
if bar is not None:
|
||||||
bar.clear()
|
bar.clear()
|
||||||
|
|
||||||
try:
|
|
||||||
del os.environ['SSHPASS']
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
returncode = p.wait()
|
returncode = p.wait()
|
||||||
|
|
||||||
if returncode == 0:
|
if returncode == 0:
|
||||||
|
|||||||
+13
-1
@@ -6,6 +6,7 @@ import os
|
|||||||
from collections.abc import Callable, Iterator
|
from collections.abc import Callable, Iterator
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from shutil import which
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -338,10 +339,21 @@ class TestReturnCodes:
|
|||||||
assert run_backup(out, [str(src)]) == 0
|
assert run_backup(out, [str(src)]) == 0
|
||||||
|
|
||||||
(args,) = fake_popen.calls
|
(args,) = fake_popen.calls
|
||||||
assert args[0] == '/usr/bin/rsync'
|
assert args[0] == which('rsync')
|
||||||
assert f'--link-dest={out}/simple_backup/{old_backups[-1]}' in args
|
assert f'--link-dest={out}/simple_backup/{old_backups[-1]}' in args
|
||||||
assert args[args.index('/') + 1] == f'{out}/simple_backup/2026-10-14_10-00-00'
|
assert args[args.index('/') + 1] == f'{out}/simple_backup/2026-10-14_10-00-00'
|
||||||
|
|
||||||
|
def test_rsync_missing(self, src: Path, out: Path, tmp_path: Path, fake_popen: type[FakePopen],
|
||||||
|
old_backups: list[str], monkeypatch: pytest.MonkeyPatch,
|
||||||
|
caplog: pytest.LogCaptureFixture) -> None:
|
||||||
|
monkeypatch.setenv('PATH', str(tmp_path / 'empty'))
|
||||||
|
|
||||||
|
assert run_backup(out, [str(src)], keep=0, remove_before=True) == 4
|
||||||
|
assert fake_popen.calls == []
|
||||||
|
# Nothing was removed
|
||||||
|
assert backups(out) == old_backups
|
||||||
|
assert 'rsync not found' in caplog.text
|
||||||
|
|
||||||
@pytest.mark.parametrize('returncode, result, removed', [
|
@pytest.mark.parametrize('returncode, result, removed', [
|
||||||
(0, 0, True),
|
(0, 0, True),
|
||||||
(24, 0, True),
|
(24, 0, True),
|
||||||
|
|||||||
+16
-6
@@ -254,9 +254,11 @@ class RecordPopen:
|
|||||||
|
|
||||||
returncode = 0
|
returncode = 0
|
||||||
calls: list[list[str]] = []
|
calls: list[list[str]] = []
|
||||||
|
envs: list[dict[str, str] | None] = []
|
||||||
|
|
||||||
def __init__(self, args: list[str], **kwargs: Any) -> None:
|
def __init__(self, args: list[str], **kwargs: Any) -> None:
|
||||||
RecordPopen.calls.append(args)
|
RecordPopen.calls.append(args)
|
||||||
|
RecordPopen.envs.append(kwargs.get('env'))
|
||||||
self.stdin = io.BytesIO()
|
self.stdin = io.BytesIO()
|
||||||
self.stdout = io.BytesIO(b'sent 1 bytes received 2 bytes\ntotal size is 3\n')
|
self.stdout = io.BytesIO(b'sent 1 bytes received 2 bytes\ntotal size is 3\n')
|
||||||
|
|
||||||
@@ -276,6 +278,7 @@ class TestRsyncArgs:
|
|||||||
def record_popen(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
def record_popen(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
RecordPopen.returncode = 0
|
RecordPopen.returncode = 0
|
||||||
RecordPopen.calls = []
|
RecordPopen.calls = []
|
||||||
|
RecordPopen.envs = []
|
||||||
monkeypatch.setattr(sb, 'Popen', RecordPopen)
|
monkeypatch.setattr(sb, 'Popen', RecordPopen)
|
||||||
# The input must exist locally
|
# The input must exist locally
|
||||||
(tmp_path / 'input').mkdir()
|
(tmp_path / 'input').mkdir()
|
||||||
@@ -322,19 +325,26 @@ class TestRsyncArgs:
|
|||||||
|
|
||||||
def test_password_auth(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_password_auth(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
monkeypatch.setattr(sb, 'which', lambda cmd: f'/usr/bin/{cmd}')
|
monkeypatch.setattr(sb, 'which', lambda cmd: f'/usr/bin/{cmd}')
|
||||||
monkeypatch.setenv('SSHPASS', 'secret')
|
monkeypatch.delenv('SSHPASS', raising=False)
|
||||||
backup = connected_backup()
|
backup = connected_backup()
|
||||||
backup.inputs = [str(tmp_path / 'input')]
|
backup.inputs = [str(tmp_path / 'input')]
|
||||||
backup._password_auth = True
|
backup._password = 'secret'
|
||||||
|
|
||||||
assert backup.run() == 0
|
assert backup.run() == 0
|
||||||
|
|
||||||
(args,) = RecordPopen.calls
|
(args,) = RecordPopen.calls
|
||||||
assert shlex.split(self.ssh_command(args)) == ['sshpass', '-e', 'ssh', '-l', 'user', '-o',
|
assert shlex.split(self.ssh_command(args)) == ['sshpass', '-e', 'ssh', '-l', 'user', '-o',
|
||||||
'StrictHostKeyChecking=accept-new']
|
'StrictHostKeyChecking=accept-new']
|
||||||
# The password must not stay in the environment
|
# Only rsync gets the password, never our own environment
|
||||||
|
(env,) = RecordPopen.envs
|
||||||
|
assert env is not None and env['SSHPASS'] == 'secret'
|
||||||
assert 'SSHPASS' not in os.environ
|
assert 'SSHPASS' not in os.environ
|
||||||
|
|
||||||
|
def test_no_password_env(self, tmp_path: Path) -> None:
|
||||||
|
self.run(tmp_path)
|
||||||
|
|
||||||
|
assert RecordPopen.envs == [None]
|
||||||
|
|
||||||
def test_failure(self, tmp_path: Path, remote_home: Path) -> None:
|
def test_failure(self, tmp_path: Path, remote_home: Path) -> None:
|
||||||
base = make_backups(remote_home, '2026-10-01_10-00-00')
|
base = make_backups(remote_home, '2026-10-01_10-00-00')
|
||||||
RecordPopen.returncode = 23
|
RecordPopen.returncode = 23
|
||||||
@@ -471,13 +481,13 @@ class TestLogin:
|
|||||||
def test_password(self, client: FakeClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_password(self, client: FakeClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
client.outcomes = [ssh_error(), ssh_error(), None]
|
client.outcomes = [ssh_error(), ssh_error(), None]
|
||||||
monkeypatch.setattr(sb, 'getpass', lambda prompt: 'secret')
|
monkeypatch.setattr(sb, 'getpass', lambda prompt: 'secret')
|
||||||
monkeypatch.setenv('SSHPASS', '')
|
monkeypatch.delenv('SSHPASS', raising=False)
|
||||||
backup = remote_backup()
|
backup = remote_backup()
|
||||||
|
|
||||||
assert backup._ssh_connect() is client
|
assert backup._ssh_connect() is client
|
||||||
assert client.connects[-1]['password'] == 'secret'
|
assert client.connects[-1]['password'] == 'secret'
|
||||||
assert backup._password_auth
|
assert backup._password == 'secret'
|
||||||
assert os.environ['SSHPASS'] == 'secret'
|
assert 'SSHPASS' not in os.environ
|
||||||
|
|
||||||
def test_wrong_password(self, client: FakeClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_wrong_password(self, client: FakeClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
client.outcomes = [ssh_error(), ssh_error(), ssh_error()]
|
client.outcomes = [ssh_error(), ssh_error(), ssh_error()]
|
||||||
|
|||||||
Reference in New Issue
Block a user