Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement SSH Proxy Host #1688

Merged
merged 19 commits into from
Apr 28, 2024
1 change: 1 addition & 0 deletions CHANGES
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
Back In Time

Version 1.4.4-dev (development of upcoming release)
* Feature: Support SSH proxy (jump) host (#1688) (@cgrinham, Christie Grinham)
* Removed: Context menu in LogViewDialog (#1578)
* Refactor: Replace Config.user() with getpass.getuser() (#1694)
* Fix: Validation of diff command settings in compare snapshots dialog (#1662) (@stcksmsh Kosta Vukicevic)
Expand Down
92 changes: 73 additions & 19 deletions common/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -653,6 +653,30 @@ def sshPrivateKeyFolder(self):
def setSshPrivateKeyFile(self, value, profile_id = None):
self.setProfileStrValue('snapshots.ssh.private_key_file', value, profile_id)

def sshProxyHost(self, profile_id=None):
#?Proxy host used to connect to remote host.;;IP or domain address
return self.profileStrValue('snapshots.ssh.proxy_host', '', profile_id)

def setSshProxyHost(self, value, profile_id=None):
self.setProfileStrValue('snapshots.ssh.proxy_host', value, profile_id)

def sshProxyPort(self, profile_id=None):
#?Proxy host port used to connect to remote host.;0-65535
return self.profileIntValue(
'snapshots.ssh.proxy_host_port', '22', profile_id)

def setSshProxyPort(self, value, profile_id = None):
self.setProfileIntValue(
'snapshots.ssh.proxy_host_port', value, profile_id)

def sshProxyUser(self, profile_id=None):
#?Remote SSH user;;local users name
return self.profileStrValue(
'snapshots.ssh.proxy_user', getpass.getuser(), profile_id)

def setSshProxyUser(self, value, profile_id=None):
self.setProfileStrValue('snapshots.ssh.proxy_user', value, profile_id)

def sshMaxArgLength(self, profile_id = None):
#?Maximum command length of commands run on remote host. This can be tested
#?for all ssh profiles in the configuration
Expand Down Expand Up @@ -698,16 +722,16 @@ def sshDefaultArgs(self, profile_id = None):
return args

def sshCommand(self,
cmd = None,
custom_args = None,
port = True,
cipher = True,
user_host = True,
ionice = True,
nice = True,
quote = False,
prefix = True,
profile_id = None):
cmd=None,
custom_args=None,
port=True,
cipher=True,
user_host=True,
ionice=True,
nice=True,
quote=False,
prefix=True,
profile_id=None):
"""
Return SSH command with all arguments.

Expand All @@ -726,46 +750,68 @@ def sshCommand(self,
Returns:
list: ssh command with chosen arguments
"""
# Refactor: Use of assert is discouraged in productive code.
# Raise Exceptions instead.
assert cmd is None or isinstance(cmd, list), "cmd '{}' is not list instance".format(cmd)
assert custom_args is None or isinstance(custom_args, list), "custom_args '{}' is not list instance".format(custom_args)
ssh = ['ssh']

ssh = ['ssh']
ssh += self.sshDefaultArgs(profile_id)

# Proxy (aka Jump host)
if self.sshProxyHost(profile_id):
ssh += ['-J', '{}@{}:{}'.format(
self.sshProxyUser(profile_id),
self.sshProxyHost(profile_id),
self.sshProxyPort(profile_id)
)]

# remote port
if port:
ssh += ['-p', str(self.sshPort(profile_id))]

# cipher used to transfer data
c = self.sshCipher(profile_id)
if cipher and c != 'default':
ssh += ['-o', 'Ciphers={}'.format(c)]
ssh += ['-o', f'Ciphers={c}']

# custom arguments
if custom_args:
ssh += custom_args

# user@host
if user_host:
ssh.append('{}@{}'.format(self.sshUser(profile_id),
self.sshHost(profile_id)))
# quote the command running on remote host
if quote and cmd:
ssh.append("'")

# run 'ionice' on remote host
if ionice and self.ioniceOnRemote(profile_id) and cmd:
ssh += ['ionice', '-c2', '-n7']

# run 'nice' on remote host
if nice and self.niceOnRemote(profile_id) and cmd:
ssh += ['nice', '-n19']

# run prefix on remote host
if prefix and cmd and self.sshPrefixEnabled(profile_id):
ssh += self.sshPrefixCmd(profile_id, cmd_type = list)
ssh += self.sshPrefixCmd(profile_id, cmd_type=type(cmd))

# add the command
if cmd:
ssh += cmd

# close quote
if quote and cmd:
ssh.append("'")

logger.debug(f'SSH command: {ssh}', self)

return ssh

#ENCFS
# EncFS
def localEncfsPath(self, profile_id = None):
#?Where to save snapshots in mode 'local_encfs'.;absolute path
return self.profileStrValue('snapshots.local_encfs.path', '', profile_id)
Expand Down Expand Up @@ -1311,17 +1357,25 @@ def setSshPrefix(self, enabled, value, profile_id = None):
self.setProfileBoolValue('snapshots.ssh.prefix.enabled', enabled, profile_id)
self.setProfileStrValue('snapshots.ssh.prefix.value', value, profile_id)

def sshPrefixCmd(self, profile_id = None, cmd_type = str):
def sshPrefixCmd(self, profile_id=None, cmd_type=str):
"""Return the config value of sshPrefix if enabled.

Dev note by buhtz (2024-04): Good opportunity to refactor. To much
implicit behavior in it.
"""
if cmd_type == list:
if self.sshPrefixEnabled(profile_id):
return shlex.split(self.sshPrefix(profile_id))
else:
return []

return []

if cmd_type == str:
if self.sshPrefixEnabled(profile_id):
return self.sshPrefix(profile_id).strip() + ' '
else:
return ''

return ''

raise TypeError(f'Unable to handle type {cmd_type}.')

def continueOnErrors(self, profile_id = None):
#?Continue on errors. This will keep incomplete snapshots rather than
Expand Down
28 changes: 20 additions & 8 deletions common/snapshots.py
Original file line number Diff line number Diff line change
Expand Up @@ -732,8 +732,9 @@ def backup(self, force = False):
self.config.PLUGIN_MANAGER.error(1)

elif (not force
and self.config.noSnapshotOnBattery()
and tools.onBattery()):
and self.config.noSnapshotOnBattery()
and tools.onBattery()):

self.setTakeSnapshotMessage(
0, _('Deferring backup while on battery'))
logger.info('Deferring backup while on battery', self)
Expand Down Expand Up @@ -761,7 +762,7 @@ def backup(self, force = False):
logger.warning(
'A backup is already running. The pid of the already '
f'running backup is in file {instance.pidFile}. Maybe '
'delete it.', self )
'delete it.', self)

# a backup is already running
self.config.PLUGIN_MANAGER.error(2)
Expand All @@ -773,7 +774,7 @@ def backup(self, force = False):
f'{restore_instance.pidFile}. Maybe delete it.', self)

else:
if (self.config.noSnapshotOnBattery ()
if (self.config.noSnapshotOnBattery()
and not tools.powerStatusAvailable()):
logger.warning('Backups disabled on battery but power '
'status is not available', self)
Expand All @@ -793,7 +794,7 @@ def backup(self, force = False):

# mount
try:
hash_id = mount.Mount(cfg = self.config).mount()
hash_id = mount.Mount(cfg=self.config).mount()

except MountException as ex:
logger.error(str(ex), self)
Expand Down Expand Up @@ -3030,8 +3031,7 @@ def path(self, *path, use_mode = []):

def iterSnapshots(cfg, includeNewSnapshot = False):
"""
Iterate over snapshots in current snapshot path. Use this in a 'for' loop
for faster processing than list object
A generator to iterate over snapshots in current snapshot path.

Args:
cfg (config.Config): current config
Expand All @@ -3042,21 +3042,33 @@ def iterSnapshots(cfg, includeNewSnapshot = False):
SID: snapshot IDs
"""
path = cfg.snapshotsFullPath()

if not os.path.exists(path):
return None

for item in os.listdir(path):

if item == NewSnapshot.NEWSNAPSHOT:
newSid = NewSnapshot(cfg)

if newSid.exists() and includeNewSnapshot:
yield newSid

continue

try:
sid = SID(item, cfg)

if sid.exists():
yield sid

# REFACTOR!
# LastSnapshotSymlink is an exception instance and could be catched
# explicit. But not sure about its purpose.
except Exception as e:
if not isinstance(e, LastSnapshotSymlink):
logger.debug("'{}' is not a snapshot ID: {}".format(item, str(e)))
logger.debug(
"'{}' is not a snapshot ID: {}".format(item, str(e)))


def listSnapshots(cfg, includeNewSnapshot = False, reverse = True):
Expand Down
Loading