Skip to content

Commit ba1ff78

Browse files
cgrinhambuhtz
andauthored
feat: SSH proxy (jump) host (bit-team#1688)
Co-authored-by: Christian Buhtz <c.buhtz@posteo.jp>
1 parent fb86465 commit ba1ff78

File tree

7 files changed

+462
-87
lines changed

7 files changed

+462
-87
lines changed

CHANGES

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
Back In Time
22

33
Version 1.4.4-dev (development of upcoming release)
4+
* Feature: Support SSH proxy (jump) host (#1688) (@cgrinham, Christie Grinham)
45
* Removed: Context menu in LogViewDialog (#1578)
56
* Refactor: Replace Config.user() with getpass.getuser() (#1694)
67
* Fix: Validation of diff command settings in compare snapshots dialog (#1662) (@stcksmsh Kosta Vukicevic)

common/config.py

Lines changed: 73 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -653,6 +653,30 @@ def sshPrivateKeyFolder(self):
653653
def setSshPrivateKeyFile(self, value, profile_id = None):
654654
self.setProfileStrValue('snapshots.ssh.private_key_file', value, profile_id)
655655

656+
def sshProxyHost(self, profile_id=None):
657+
#?Proxy host used to connect to remote host.;;IP or domain address
658+
return self.profileStrValue('snapshots.ssh.proxy_host', '', profile_id)
659+
660+
def setSshProxyHost(self, value, profile_id=None):
661+
self.setProfileStrValue('snapshots.ssh.proxy_host', value, profile_id)
662+
663+
def sshProxyPort(self, profile_id=None):
664+
#?Proxy host port used to connect to remote host.;0-65535
665+
return self.profileIntValue(
666+
'snapshots.ssh.proxy_host_port', '22', profile_id)
667+
668+
def setSshProxyPort(self, value, profile_id = None):
669+
self.setProfileIntValue(
670+
'snapshots.ssh.proxy_host_port', value, profile_id)
671+
672+
def sshProxyUser(self, profile_id=None):
673+
#?Remote SSH user;;local users name
674+
return self.profileStrValue(
675+
'snapshots.ssh.proxy_user', getpass.getuser(), profile_id)
676+
677+
def setSshProxyUser(self, value, profile_id=None):
678+
self.setProfileStrValue('snapshots.ssh.proxy_user', value, profile_id)
679+
656680
def sshMaxArgLength(self, profile_id = None):
657681
#?Maximum command length of commands run on remote host. This can be tested
658682
#?for all ssh profiles in the configuration
@@ -698,16 +722,16 @@ def sshDefaultArgs(self, profile_id = None):
698722
return args
699723

700724
def sshCommand(self,
701-
cmd = None,
702-
custom_args = None,
703-
port = True,
704-
cipher = True,
705-
user_host = True,
706-
ionice = True,
707-
nice = True,
708-
quote = False,
709-
prefix = True,
710-
profile_id = None):
725+
cmd=None,
726+
custom_args=None,
727+
port=True,
728+
cipher=True,
729+
user_host=True,
730+
ionice=True,
731+
nice=True,
732+
quote=False,
733+
prefix=True,
734+
profile_id=None):
711735
"""
712736
Return SSH command with all arguments.
713737
@@ -726,46 +750,68 @@ def sshCommand(self,
726750
Returns:
727751
list: ssh command with chosen arguments
728752
"""
753+
# Refactor: Use of assert is discouraged in productive code.
754+
# Raise Exceptions instead.
729755
assert cmd is None or isinstance(cmd, list), "cmd '{}' is not list instance".format(cmd)
730756
assert custom_args is None or isinstance(custom_args, list), "custom_args '{}' is not list instance".format(custom_args)
731-
ssh = ['ssh']
757+
758+
ssh = ['ssh']
732759
ssh += self.sshDefaultArgs(profile_id)
760+
761+
# Proxy (aka Jump host)
762+
if self.sshProxyHost(profile_id):
763+
ssh += ['-J', '{}@{}:{}'.format(
764+
self.sshProxyUser(profile_id),
765+
self.sshProxyHost(profile_id),
766+
self.sshProxyPort(profile_id)
767+
)]
768+
733769
# remote port
734770
if port:
735771
ssh += ['-p', str(self.sshPort(profile_id))]
772+
736773
# cipher used to transfer data
737774
c = self.sshCipher(profile_id)
738775
if cipher and c != 'default':
739-
ssh += ['-o', 'Ciphers={}'.format(c)]
776+
ssh += ['-o', f'Ciphers={c}']
777+
740778
# custom arguments
741779
if custom_args:
742780
ssh += custom_args
781+
743782
# user@host
744783
if user_host:
745784
ssh.append('{}@{}'.format(self.sshUser(profile_id),
746785
self.sshHost(profile_id)))
747786
# quote the command running on remote host
748787
if quote and cmd:
749788
ssh.append("'")
789+
750790
# run 'ionice' on remote host
751791
if ionice and self.ioniceOnRemote(profile_id) and cmd:
752792
ssh += ['ionice', '-c2', '-n7']
793+
753794
# run 'nice' on remote host
754795
if nice and self.niceOnRemote(profile_id) and cmd:
755796
ssh += ['nice', '-n19']
797+
756798
# run prefix on remote host
757799
if prefix and cmd and self.sshPrefixEnabled(profile_id):
758-
ssh += self.sshPrefixCmd(profile_id, cmd_type = list)
800+
ssh += self.sshPrefixCmd(profile_id, cmd_type=type(cmd))
801+
759802
# add the command
760803
if cmd:
761804
ssh += cmd
805+
762806
# close quote
763807
if quote and cmd:
764808
ssh.append("'")
765809

810+
logger.debug(f'SSH command: {ssh}', self)
811+
766812
return ssh
767813

768-
#ENCFS
814+
# EncFS
769815
def localEncfsPath(self, profile_id = None):
770816
#?Where to save snapshots in mode 'local_encfs'.;absolute path
771817
return self.profileStrValue('snapshots.local_encfs.path', '', profile_id)
@@ -1311,17 +1357,25 @@ def setSshPrefix(self, enabled, value, profile_id = None):
13111357
self.setProfileBoolValue('snapshots.ssh.prefix.enabled', enabled, profile_id)
13121358
self.setProfileStrValue('snapshots.ssh.prefix.value', value, profile_id)
13131359

1314-
def sshPrefixCmd(self, profile_id = None, cmd_type = str):
1360+
def sshPrefixCmd(self, profile_id=None, cmd_type=str):
1361+
"""Return the config value of sshPrefix if enabled.
1362+
1363+
Dev note by buhtz (2024-04): Good opportunity to refactor. To much
1364+
implicit behavior in it.
1365+
"""
13151366
if cmd_type == list:
13161367
if self.sshPrefixEnabled(profile_id):
13171368
return shlex.split(self.sshPrefix(profile_id))
1318-
else:
1319-
return []
1369+
1370+
return []
1371+
13201372
if cmd_type == str:
13211373
if self.sshPrefixEnabled(profile_id):
13221374
return self.sshPrefix(profile_id).strip() + ' '
1323-
else:
1324-
return ''
1375+
1376+
return ''
1377+
1378+
raise TypeError(f'Unable to handle type {cmd_type}.')
13251379

13261380
def continueOnErrors(self, profile_id = None):
13271381
#?Continue on errors. This will keep incomplete snapshots rather than

common/snapshots.py

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -732,8 +732,9 @@ def backup(self, force = False):
732732
self.config.PLUGIN_MANAGER.error(1)
733733

734734
elif (not force
735-
and self.config.noSnapshotOnBattery()
736-
and tools.onBattery()):
735+
and self.config.noSnapshotOnBattery()
736+
and tools.onBattery()):
737+
737738
self.setTakeSnapshotMessage(
738739
0, _('Deferring backup while on battery'))
739740
logger.info('Deferring backup while on battery', self)
@@ -761,7 +762,7 @@ def backup(self, force = False):
761762
logger.warning(
762763
'A backup is already running. The pid of the already '
763764
f'running backup is in file {instance.pidFile}. Maybe '
764-
'delete it.', self )
765+
'delete it.', self)
765766

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

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

794795
# mount
795796
try:
796-
hash_id = mount.Mount(cfg = self.config).mount()
797+
hash_id = mount.Mount(cfg=self.config).mount()
797798

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

30313032
def iterSnapshots(cfg, includeNewSnapshot = False):
30323033
"""
3033-
Iterate over snapshots in current snapshot path. Use this in a 'for' loop
3034-
for faster processing than list object
3034+
A generator to iterate over snapshots in current snapshot path.
30353035
30363036
Args:
30373037
cfg (config.Config): current config
@@ -3042,21 +3042,33 @@ def iterSnapshots(cfg, includeNewSnapshot = False):
30423042
SID: snapshot IDs
30433043
"""
30443044
path = cfg.snapshotsFullPath()
3045+
30453046
if not os.path.exists(path):
30463047
return None
3048+
30473049
for item in os.listdir(path):
3050+
30483051
if item == NewSnapshot.NEWSNAPSHOT:
30493052
newSid = NewSnapshot(cfg)
3053+
30503054
if newSid.exists() and includeNewSnapshot:
30513055
yield newSid
3056+
30523057
continue
3058+
30533059
try:
30543060
sid = SID(item, cfg)
3061+
30553062
if sid.exists():
30563063
yield sid
3064+
3065+
# REFACTOR!
3066+
# LastSnapshotSymlink is an exception instance and could be catched
3067+
# explicit. But not sure about its purpose.
30573068
except Exception as e:
30583069
if not isinstance(e, LastSnapshotSymlink):
3059-
logger.debug("'{}' is not a snapshot ID: {}".format(item, str(e)))
3070+
logger.debug(
3071+
"'{}' is not a snapshot ID: {}".format(item, str(e)))
30603072

30613073

30623074
def listSnapshots(cfg, includeNewSnapshot = False, reverse = True):

0 commit comments

Comments
 (0)