diff --git a/.travis.yml b/.travis.yml index a7a4514dd..129da2a15 100644 --- a/.travis.yml +++ b/.travis.yml @@ -42,7 +42,7 @@ before_install: - sudo apt-get -qq update # install screen, and util-linux (provides flock) for test_sshtools - sudo apt-get install -y sshfs screen util-linux libdbus-1-dev - - sudo apt-get install -y ruby rubygems asciidoctor encfs + - sudo apt-get install -y ruby rubygems asciidoctor encfs gocryptfs - sudo gem install asciidoctor jobs: diff --git a/common/config.py b/common/config.py index d81a1a6aa..2b0052db8 100644 --- a/common/config.py +++ b/common/config.py @@ -45,6 +45,7 @@ import logger import sshtools import encfstools +import gocryptfstools import password import pluginmanager import schedule @@ -238,7 +239,7 @@ def __init__(self, config_path=None, data_path=None): sshtools.SSH, _('SSH'), _('SSH private key'), False), 'local_encfs': ( encfstools.EncFS_mount, - _('Local encrypted'), + _('Local encrypted') + ' (EncFS)', _('Encryption'), False ), @@ -247,7 +248,13 @@ def __init__(self, config_path=None, data_path=None): _('SSH encrypted'), _('SSH private key'), _('Encryption') - ) + ), + 'local_gocryptfs':( + gocryptfstools.GocryptfsMount, + _('Local encrypted') + ' (gocryptfs)', + _('Encryption'), + False + ), } # Deprecated: #2176 @@ -383,7 +390,7 @@ def get_snapshots_mountpoint(self, profile_id=None, mode=None, tmp_mount=False): if mode == 'local': return self.get_snapshots_path(profile_id) - # else: ssh/local_encfs/ssh_encfs + # else: ssh/local_encfs/ssh_encfs/local_gocryptfs symlink = f'{profile_id}_{os.getpid()}' if tmp_mount: @@ -425,7 +432,7 @@ def is_mode_encrypted(self, profile_id=None): def snapshotsMode(self, profile_id=None): #? Use mode (or backend) for this snapshot. Look at 'man backintime' - #? section 'Modes'.;local|local_encfs|ssh|ssh_encfs + #? section 'Modes'.;local|local_encfs|ssh|ssh_encfs|local_gocryptfs return self.profileStrValue('snapshots.mode', 'local', profile_id) def setSnapshotsMode(self, value, profile_id = None): @@ -723,6 +730,14 @@ def localEncfsPath(self, profile_id = None): def setLocalEncfsPath(self, value, profile_id = None): self.setProfileStrValue('snapshots.local_encfs.path', value, profile_id) + # gocryptfs + def localGocryptfsPath(self, profile_id = None): + #?Where to save snapshots in mode 'local_gocryptfs'.;absolute path + return self.profileStrValue('snapshots.local_gocryptfs.path', '', profile_id) + + def setLocalGocryptfsPath(self, value, profile_id = None): + self.setProfileStrValue('snapshots.local_gocryptfs.path', value, profile_id) + def passwordSave(self, profile_id = None, mode = None): if mode is None: mode = self.snapshotsMode(profile_id) diff --git a/common/encfstools.py b/common/encfstools.py index ff51bb80f..461f35d81 100644 --- a/common/encfstools.py +++ b/common/encfstools.py @@ -1,5 +1,7 @@ # SPDX-FileCopyrightText: © 2012-2022 Germar Reitze # SPDX-FileCopyrightText: © 2012-2022 Taylor Raack +# SPDX-FileCopyrightText: © 2025 David Wales (@daviewales) +# SPDX-FileCopyrightText: © 2025 Christian Buhtz # # SPDX-License-Identifier: GPL-2.0-or-later # @@ -16,7 +18,7 @@ import config import encode import password -import password_ipc +from password_ipc import TempPasswordThread import tools import sshtools import logger @@ -28,15 +30,7 @@ class EncFS_mount(MountControl): """Mount encrypted paths with encfs.""" def __init__(self, *args, **kwargs): - # # TODO: Remove these debug calls as they are just to help me - # # setup testing! - # logger.debug("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!") - # logger.debug("REMOVE THESE TEMPORARY DEBUG LINES!") - # logger.debug("EncFS_mount args:") - # logger.debug(str(args)) - # logger.debug("EncFS_mount kwargs:") - # logger.debug(str(kwargs)) - # logger.debug("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!") + # logger.debug("EncFS_mount.init() :: {args=} {kwargs=}") # DEBUG # init MountControl super(EncFS_mount, self).__init__(*args, **kwargs) @@ -46,16 +40,18 @@ def __init__(self, *args, **kwargs): self.reverse = None self.config_path = None - self.setattrKwargs('path', self.config.localEncfsPath(self.profile_id), **kwargs) - # print(f"{self.path=}") # DEBUG + self.setattrKwargs( + 'path', self.config.localEncfsPath(self.profile_id), **kwargs) + # logger.debug("EncFS_mount.init() :: {self.path=}") # DEBUG self.setattrKwargs('reverse', False, **kwargs) self.setattrKwargs('config_path', None, **kwargs) - self.setattrKwargs('password', None, store = False, **kwargs) + self.setattrKwargs('password', None, store=False, **kwargs) self.setattrKwargs('hash_id_1', None, **kwargs) self.setattrKwargs('hash_id_2', None, **kwargs) self.setDefaultArgs() + # pylint: disable=duplicate-code self.mountproc = 'encfs' self.log_command = '%s: %s' % (self.mode, self.path) self.symlink_subfolder = None @@ -64,37 +60,86 @@ def _mount(self): """ mount the service """ + if self.password is None: - self.password = self.config.password(self.parent, self.profile_id, self.mode) - logger.debug('Provide password through temp FIFO', self) - thread = password_ipc.TempPasswordThread(self.password) + self.password = self.config.password( + self.parent, self.profile_id, self.mode) + + # Dev note (2026-01, buhtz): + # Password flow overview: + # + # 1. Back In Time creates a TempPasswordThread and passes the password + # to it. + # 2. The thread creates a temporary FIFO and blocks while writing the + # password to it, waiting for a reader. + # 3. Back In Time starts encfs with "--extpass=backintime-askpass". + # 4. The FIFO path is passed via the environment variable ASKPASS_TEMP. + # 5. encfs invokes backintime-askpass as an external password helper. + # 6. backintime-askpass reads the FIFO path from ASKPASS_TEMP, opens + # the FIFO, reads the password, and writes it to stdout. + # 7. encfs reads the password from backintime-askpass's stdout. + # 8. After the read completes, the FIFO is removed and the thread + # exits. + # + # Result: + # The password is transferred exactly once, synchronously, via a FIFO, + # without appearing on the command line, in files, or in the process + # list. + # + # Reason: + # It is about security. It minimizes password lifetime and exposure. + # Password never appears in a shell context, is transffered only once. + + # Prepare the password-fifo-thread + thread = TempPasswordThread(self.password) env = self.env() env['ASKPASS_TEMP'] = thread.temp_file + + # Start thread and write password to FIFO with thread.starter(): + + # build encfs command and provide "backintime-askpass" as + # password helper encfs = [self.mountproc, '--extpass=backintime-askpass'] + if self.reverse: encfs += ['--reverse'] + if not self.isConfigured(): encfs += ['--standard'] + encfs += [self.path, self.currentMountpoint] - logger.debug('Call mount command: %s' - %' '.join(encfs), - self) - - proc = subprocess.Popen(encfs, env = env, - stdout = subprocess.PIPE, - stderr = subprocess.STDOUT, - universal_newlines = True) + logger.debug('Call mount command: ' + ' '.join(encfs), self) + + # Encfs aks backintime-askpass for the password. + # backintime-askpass will read the password from FIFO and provide + # it via return on stdout to the encfs process + proc = subprocess.Popen( + encfs, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + universal_newlines=True + ) output = proc.communicate()[0] + self.backupConfig() + if proc.returncode: + msg = _('Unable to mount "{command}"').format( + command=' '.join(encfs) + ) raise MountException( - '{}:\n\n{}\n\n{}'.format( - _("Unable to mount '{command}'") - .format(command=' '.join(encfs)), - output, - f"Return code: {proc.returncode}", - )) + f'{msg}:\n\n{output}\n\nReturn code: {proc.returncode}' + ) + + def init_backend(self): + """Empty for Encfs because initialization happens implicite. + + Init happens in mount() via "--standard" switch on encfs if + self.isConfigured() is False. + """ + return def preMountCheck(self, first_run=False): """Check what ever conditions must be given for the mount. @@ -123,6 +168,7 @@ def configFile(self): return encfs config file """ f = '.encfs6.xml' + # pylint: disable=duplicate-code if self.config_path is None: cfg = os.path.join(self.path, f) else: @@ -181,11 +227,18 @@ def checkVersion(self): """ logger.debug('Check version', self) if self.reverse: - proc = subprocess.Popen(['encfs', '--version'], - stdout = subprocess.PIPE, - stderr = subprocess.STDOUT, - universal_newlines = True) + proc = subprocess.Popen( + [ + 'encfs', + '--version' + ], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + universal_newlines=True + ) + output = proc.communicate()[0] + m = re.search(r'(\d\.\d\.\d)', output) if m and Version(m.group(1)) <= Version('1.7.2'): logger.debug('Wrong encfs version %s' % m.group(1), self) @@ -194,32 +247,41 @@ def checkVersion(self): 'option --reverse. Please update encfs.') def backupConfig(self): - """ - create a backup of encfs config file into local config folder - so in cases of the config file get deleted or corrupt user can restore - it from there + """Create a backup of encfs config file into local config folder. + + In cases of the config file get deleted or corrupt user can restore + it from there. """ cfg = self.configFile() if not os.path.isfile(cfg): - logger.warning('No encfs config in %s. Skip backup of config file.' %cfg, self) + logger.warning( + f'No encfs config in {cfg}. Skip backup of config file.', self + ) return + backup_folder = self.config.encfsconfigBackupFolder(self.profile_id) tools.makeDirs(backup_folder) + old_backups = os.listdir(backup_folder) - old_backups.sort(reverse = True) + old_backups.sort(reverse=True) + if len(old_backups): last_backup = os.path.join(backup_folder, old_backups[0]) - #don't create a new backup if config hasn't changed + # Don't create a new backup if config hasn't changed if tools.md5sum(cfg) == \ tools.md5sum(last_backup): logger.debug('Encfs config did not change. Skip backup', self) return - new_backup_file = '.'.join((os.path.basename(cfg), datetime.now().strftime('%Y%m%d%H%M'))) + new_backup_file = '.'.join(( + os.path.basename(cfg), + datetime.now().strftime('%Y%m%d%H%M') + )) new_backup = os.path.join(backup_folder, new_backup_file) - logger.debug('Create backup of encfs config %s to %s' - %(cfg, new_backup), self) + logger.debug( + f'Create backup of encfs config {cfg} to {new_backup}', self + ) shutil.copy2(cfg, new_backup) @@ -229,7 +291,15 @@ class EncFS_SSH(EncFS_mount): Mount / with encfs --reverse. rsync will then sync the encrypted view on / to the remote path """ - def __init__(self, cfg = None, profile_id = None, mode = None, parent = None,*args, **kwargs): + + def __init__( + self, + cfg=None, + profile_id=None, + mode=None, + parent=None, + *args, **kwargs + ): self.config = cfg or config.Config() self.profile_id = profile_id or self.config.currentProfile() self.mode = mode @@ -240,8 +310,13 @@ def __init__(self, cfg = None, profile_id = None, mode = None, parent = None,*ar self.args = args self.kwargs = kwargs - self.ssh = sshtools.SSH(*self.args, symlink = False, **self.splitKwargs('ssh')) - self.rev_root = EncFS_mount(*self.args, symlink = False, **self.splitKwargs('encfs_reverse')) + self.ssh = sshtools.SSH( + *self.args, symlink=False, **self.splitKwargs('ssh') + ) + self.rev_root = EncFS_mount( + *self.args, symlink=False, **self.splitKwargs('encfs_reverse') + ) + super(EncFS_SSH, self).__init__(*self.args, **self.splitKwargs('encfs')) def mount(self, *args, **kwargs): @@ -251,49 +326,74 @@ def mount(self, *args, **kwargs): """ logger.debug('Mount sshfs', self) self.ssh.mount(*args, **kwargs) - #mount fsroot with encfs --reverse first. - #If the config does not exist already this will make sure - #the new created config works with --reverse + # mount fsroot with encfs --reverse first. + # If the config does not exist already this will make sure + # the new created config works with --reverse + if not os.path.isfile(self.configFile()): - #encfs >= 1.8.0 changed behavior when ENCFS6_CONFIG environ variable - #file does not exist. It will not create a new one anymore but just fail. - #As encfs would create the config in /.encfs6.xml (which will most likely fail) - #we need to mount a temp folder with reverse first and copy the config when done. - logger.debug('Mount temp directory with encfs --reverse to create a new encfs config', self) + # encfs >= 1.8.0 changed behavior when ENCFS6_CONFIG environ + # variable file does not exist. It will not create a new one + # anymore but just fail. As encfs would create the config in + # /.encfs6.xml (which will most likely fail) we need to mount a + # temp folder with reverse first and copy the config when done. + + # logger.debug( + # 'Mount temp directory with encfs --reverse to create a new ' + # 'encfs config', + # self + # ) + with tempfile.TemporaryDirectory() as src: tmp_kwargs = self.splitKwargs('encfs_reverse') tmp_kwargs['path'] = src tmp_kwargs['config_path'] = src - tmp_mount = EncFS_mount(*self.args, symlink = False, **tmp_kwargs) + + tmp_mount = EncFS_mount( + *self.args, symlink=False, **tmp_kwargs) tmp_mount.mount(*args, **kwargs) tmp_mount.umount() + cfg = tmp_mount.configFile() + if os.path.isfile(cfg): - logger.debug('Copy new encfs config %s to its original place %s' %(cfg, self.ssh.currentMountpoint), self) + logger.debug( + f'Copy new encfs config {cfg} to its original place ' + f'{self.ssh.currentMountpoint}', + self + ) shutil.copy2(cfg, self.ssh.currentMountpoint) + else: - logger.error('New encfs config %s not found' %cfg, self) - logger.debug('Mount local filesystem root with encfs --reverse', self) + logger.error(f'New encfs config {cfg} not found', self) + + # logger.debug('Mount local filesystem root with encfs --reverse', self) self.rev_root.mount(*args, **kwargs) - logger.debug('Mount encfs', self) + # logger.debug('Mount encfs', self) kwargs['check'] = False + ret = super(EncFS_SSH, self).mount(*args, **kwargs) + self.config.ENCODE = Encode(self) + return ret def umount(self, *args, **kwargs): + """Close 'encfsctl encode' process and set config.ENCODE back to the + dummy class. Call umount for encfs, encfs --reverse and sshfs """ - close 'encfsctl encode' process and set config.ENCODE back to the dummy class. - call umount for encfs, encfs --reverse and sshfs - """ + self.config.ENCODE.close() self.config.ENCODE = encode.Bounce() - logger.debug('Unmount encfs', self) + + # logger.debug('Unmount encfs', self) + super(EncFS_SSH, self).umount(*args, **kwargs) - logger.debug('Unmount local filesystem root mount encfs --reverse', self) + # logger.debug('Unmount local filesystem root mount encfs --reverse', self) + self.rev_root.umount(*args, **kwargs) - logger.debug('Unmount sshfs', self) + # logger.debug('Unmount sshfs', self) + self.ssh.umount(*args, **kwargs) def preMountCheck(self, *args, **kwargs): @@ -312,47 +412,74 @@ def splitKwargs(self, mode): split all given arguments for the desired mount class """ d = self.kwargs.copy() - d['cfg'] = self.config + d['cfg'] = self.config d['profile_id'] = self.profile_id - d['mode'] = self.mode - d['parent'] = self.parent + d['mode'] = self.mode + d['parent'] = self.parent + if mode == 'ssh': if 'path' in d: d.pop('path') + if 'ssh_path' in d: d['path'] = d.pop('ssh_path') + if 'ssh_password' in d: d['password'] = d.pop('ssh_password') else: - d['password'] = self.config.password(parent = self.parent, profile_id = self.profile_id, mode = self.mode) + d['password'] = self.config.password( + parent=self.parent, + profile_id=self.profile_id, + mode=self.mode + ) + if 'hash_id' in d: d.pop('hash_id') + if 'hash_id_2' in d: d['hash_id'] = d['hash_id_2'] + return d elif mode == 'encfs': d['path'] = self.ssh.currentMountpoint d['hash_id_1'] = self.rev_root.hash_id d['hash_id_2'] = self.ssh.hash_id + if 'encfs_password' in d: d['password'] = d.pop('encfs_password') + else: - d['password'] = self.config.password(parent = self.parent, profile_id = self.profile_id, mode = self.mode, pw_id = 2) + d['password'] = self.config.password( + parent=self.parent, + profile_id=self.profile_id, + mode=self.mode, + pw_id=2 + ) + return d elif mode == 'encfs_reverse': d['reverse'] = True d['path'] = '/' d['config_path'] = self.ssh.currentMountpoint + if 'encfs_password' in d: d['password'] = d.pop('encfs_password') else: - d['password'] = self.config.password(parent = self.parent, profile_id = self.profile_id, mode = self.mode, pw_id = 2) + d['password'] = self.config.password( + parent=self.parent, + profile_id=self.profile_id, + mode=self.mode, + pw_id=2 + ) + if 'hash_id' in d: d.pop('hash_id') + if 'hash_id_1' in d: d['hash_id'] = d['hash_id_1'] + return d @@ -372,7 +499,7 @@ def __init__(self, encfs): if not self.remote_path[-1] == os.sep: self.remote_path += os.sep - #precompile some regular expressions + # Precompile some regular expressions self.re_asterisk = re.compile(r'\*') self.re_separate_asterisk = re.compile(r'(.*?)(\*+)(.*)') @@ -383,19 +510,20 @@ def startProcess(self): """ start 'encfsctl encode' process in pipe mode. """ - thread = password_ipc.TempPasswordThread(self.password) + thread = TempPasswordThread(self.password) env = self.encfs.env() env['ASKPASS_TEMP'] = thread.temp_file with thread.starter(): - logger.debug('start \'encfsctl encode\' process', self) encfsctl = ['encfsctl', 'encode', '--extpass=backintime-askpass', '/'] - logger.debug('Call command: %s' - %' '.join(encfsctl), - self) - self.p = subprocess.Popen(encfsctl, env = env, bufsize = 0, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - universal_newlines = True) + logger.debug(f'Call command: {encfsctl}', self) + self.p = subprocess.Popen( + encfsctl, + env=env, + bufsize=0, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + universal_newlines=True + ) def path(self, path): """ @@ -403,16 +531,22 @@ def path(self, path): """ if not 'p' in vars(self): self.startProcess() + if not self.p.returncode is None: - logger.warning('\'encfsctl encode\' process terminated. Restarting.', self) + logger.warning( + "'encfsctl encode' process terminated. Restarting.", self + ) del self.p + self.startProcess() + self.p.stdin.write(path + '\n') ret = self.p.stdout.readline().strip('\n') + if not len(ret) and len(path): - logger.debug('Failed to encode %s. Got empty string' - %path, self) + logger.debug(f'Failed to encode {path}. Got empty string', self) raise EncodeValueError() + return ret def exclude(self, path): @@ -432,32 +566,43 @@ def exclude(self, path): path_ = path[:] while True: - #search for foo/*, foo/*/bar, */bar or **/bar - #but not foo* or foo/*bar + # Search for foo/*, foo/*/bar, */bar or **/bar + # but not foo* or foo/*bar m = self.re_separate_asterisk.search(path_) + if m is None: return None + if m.group(1): if not m.group(1).endswith(os.sep): return None enc = os.path.join(enc, self.path(m.group(1))) + enc = os.path.join(enc, m.group(2)) + if m.group(3): if not m.group(3).startswith(os.sep): return None + m1 = self.re_asterisk.search(m.group(3)) + if m1 is None: enc = os.path.join(enc, self.path(m.group(3))) break + else: path_ = m.group(3) continue + else: break + else: enc = self.path(path) + if os.path.isabs(path): return os.path.join(os.sep, enc) + return enc def include(self, path): @@ -471,6 +616,7 @@ def remote(self, path): encode the path on remote host starting from backintime/host/user/... """ enc_path = self.path(path[len(self.remote_path):]) + return os.path.join(self.remote_path, enc_path) def close(self): @@ -478,7 +624,7 @@ def close(self): stop encfsctl process """ if 'p' in vars(self) and self.p.returncode is None: - logger.debug('stop \'encfsctl encode\' process', self) + logger.debug("stop 'encfsctl encode' process", self) self.p.communicate() @@ -492,10 +638,10 @@ def __init__(self, cfg, string=True): self.mode = cfg.snapshotsMode() if self.mode == 'local_encfs': - self.password = cfg.password(pw_id = 1) + self.password = cfg.password(pw_id=1) elif self.mode == 'ssh_encfs': - self.password = cfg.password(pw_id = 2) + self.password = cfg.password(pw_id=2) self.encfs = cfg.SNAPSHOT_MODES[self.mode][0](cfg) self.remote_path = cfg.sshSnapshotsPath() @@ -513,19 +659,21 @@ def __init__(self, cfg, string=True): takeSnapshot = _('Take snapshot') \ .replace('Schnappschuss', '(?:Schnappschuss|Snapshot)') - #precompile some regular expressions host, _post, user, path, _cipher = cfg.sshHostUserPortPathCipher() - #replace: --exclude"" or --include"" + + # replace: --exclude"" or --include"" self.re_include_exclude = re.compile( r'(--(?:ex|in)clude=")(.*?)(")') # codespell-ignore - #replace: 'USER@HOST:"PATH"' - self.re_remote_path = re.compile(r'(\'%s@%s:"%s)(.*?)("\')' %(user, host, path)) + # replace: 'USER@HOST:"PATH"' + self.re_remote_path = re.compile( + r'(\'%s@%s:"%s)(.*?)("\')' % (user, host, path) + ) - #replace: --link-dest="../../" - self.re_link_dest = re.compile(r'(--link-dest="\.\./\.\./)(.*?)(")') + # replace: --link-dest="../../" + self.re_link_dest = re.compile(r'(--link-dest="\.\./\.\./)(.*?)(")') - #search for: [C] + # search for: [C] self.re_change = re.compile(r'(^\[C\] .{11} )(.*)') #search for: [I] Take snapshot (rsync: BACKINTIME: ) @@ -550,22 +698,24 @@ def __init__(self, cfg, string=True): pattern = [] pattern.append(r' rsync: readlink_stat\(".*?mountpoint/') pattern.append(r' rsync: send_files failed to open ".*?mountpoint/') + if self.remote_path == './': pattern.append(r' rsync: recv_generator: failed to stat "/home/[^/]*/') pattern.append(r' rsync: recv_generator: mkdir "/home/[^/]*/') else: pattern.append(r' rsync: recv_generator: failed to stat ".*?{}'.format(self.remote_path)) pattern.append(r' rsync: recv_generator: mkdir ".*?{}'.format(self.remote_path)) + pattern.append(r' rsync: .*?".*?mountpoint/') self.re_error = re.compile(r'(^(?:\[E\] )?Error:(?:%s))(.*?)(".*)' % '|'.join(pattern)) - #search for: [I] ssh USER@HOST cp -aRl "PATH"* "PATH" + # search for: [I] ssh USER@HOST cp -aRl "PATH"* "PATH" self.re_info_cp= re.compile(r'(^\[I\] .*? cp -aRl "%s/)(.*?)("\* "%s/)(.*?)(")' % (path, path)) - #search for all chars except * + # search for all chars except * self.re_all_except_asterisk = re.compile(r'[^\*]+') - #search for: -> + # search for: -> self.re_all_except_arrow = re.compile(r'(.*?)((?: [-=]> )+)(.*)') #skip: [I] Take snapshot (rsync: sending incremental file list) @@ -584,10 +734,8 @@ def __init__(self, cfg, string=True): self.re_skip = re.compile(r'^(?:\[I\] )?%s \(rsync: (%s)' % (takeSnapshot, '|'.join(pattern))) self.string = string - if string: - self.newline = '\n' - else: - self.newline = b'\n' + + self.newline = '\n' if string else b'\n' def __del__(self): self.close() @@ -596,20 +744,28 @@ def startProcess(self): """ start 'encfsctl decode' process in pipe mode. """ - thread = password_ipc.TempPasswordThread(self.password) + thread = TempPasswordThread(self.password) env = os.environ.copy() env['ASKPASS_TEMP'] = thread.temp_file + with thread.starter(): - logger.debug('start \'encfsctl decode\' process', self) - encfsctl = ['encfsctl', 'decode', '--extpass=backintime-askpass', self.encfs.path] - logger.debug('Call command: %s' - %' '.join(encfsctl), - self) - self.p = subprocess.Popen(encfsctl, env = env, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - universal_newlines = self.string, #return string (if True) or bytes - bufsize = 0) + encfsctl = [ + 'encfsctl', + 'decode', + '--extpass=backintime-askpass', + self.encfs.path + ] + logger.debug(f'Call command: {encfsctl}', self) + + self.p = subprocess.Popen( + encfsctl, + env=env, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + # return string (if True) or bytes + universal_newlines=self.string, + bufsize=0 + ) def path(self, path): """ @@ -620,20 +776,27 @@ def path(self, path): assert isinstance(path, str), 'path is not str type: %s' % path else: assert isinstance(path, bytes), 'path is not bytes type: %s' % path + if not 'p' in vars(self): self.startProcess() + if not self.p.returncode is None: - logger.warning('\'encfsctl decode\' process terminated. Restarting.', self) + logger.warning( + "'encfsctl decode' process terminated. Restarting.", self + ) + del self.p self.startProcess() + self.p.stdin.write(path + self.newline) ret = self.p.stdout.readline() ret = ret.strip(self.newline) + if ret: return ret + return path - #TODO: rename this, 'list' is corrupting sphinx doc def list(self, list_): """ decode a list of paths @@ -641,37 +804,44 @@ def list(self, list_): output = [] for path in list_: output.append(self.path(path)) + return output def log(self, line): """ decode paths in takesnapshot.log """ - #rsync cmd + # rsync cmd if line.startswith('[I] rsync') or line.startswith('[I] nocache rsync'): line = self.re_include_exclude.sub(self.replace, line) line = self.re_remote_path.sub(self.replace, line) line = self.re_link_dest.sub(self.replace, line) return line - #[C] Change lines + + # [C] Change lines m = self.re_change.match(line) if not m is None: return m.group(1) + self.pathWithArrow(m.group(2)) - #[I] Information lines + + # [I] Information lines m = self.re_skip.match(line) if not m is None: return line + m = self.re_info.match(line) if not m is None: return m.group(1) + self.pathWithArrow(m.group(2)) + m.group(3) - #[E] Error lines + + # [E] Error lines m = self.re_error.match(line) if not m is None: return m.group(1) + self.path(m.group(2)) + m.group(3) - #cp cmd + + # cp cmd m = self.re_info_cp.match(line) if not m is None: return m.group(1) + self.path(m.group(2)) + m.group(3) + self.path(m.group(4)) + m.group(5) + return line def replace(self, m): @@ -679,8 +849,10 @@ def replace(self, m): return decoded string for re.sub """ decrypt = self.re_all_except_asterisk.sub(self.pathMatch, m.group(2)) + if os.path.isabs(m.group(2)): decrypt = os.path.join(os.sep, decrypt) + return m.group(1) + decrypt + m.group(3) def pathMatch(self, m): @@ -690,12 +862,14 @@ def pathMatch(self, m): return self.path(m.group(0)) def pathWithArrow(self, path): - """ - rsync print symlinks like 'dest -> src'. This will decode both and also normal paths + """rsync print symlinks like 'dest -> src'. This will decode both and + also normal paths """ m = self.re_all_except_arrow.match(path) + if not m is None: return self.path(m.group(1)) + m.group(2) + self.path(m.group(3)) + else: return self.path(path) @@ -707,6 +881,7 @@ def remote(self, path): remote_path = self.remote_path.encode() dec_path = self.path(path[len(remote_path):]) + return os.path.join(remote_path, dec_path) def close(self): diff --git a/common/gocryptfstools.py b/common/gocryptfstools.py new file mode 100644 index 000000000..43326ee4a --- /dev/null +++ b/common/gocryptfstools.py @@ -0,0 +1,169 @@ +# SPDX-FileCopyrightText: © 2017 Germar Reitze +# SPDX-FileCopyrightText: © 2025 David Wales (@daviewales) +# SPDX-FileCopyrightText: © 2025 Christian Buhtz +# +# SPDX-License-Identifier: GPL-2.0-or-later +# +# This file is part of the program "Back In time" which is released under GNU +# General Public License v2 (GPLv2). See LICENSES directory or go to +# . + +import os +import subprocess + +import logger +from password_ipc import TempPasswordThread +from mount import MountControl +from exceptions import MountException + + +class GocryptfsMount(MountControl): + """ + """ + + def __init__(self, *args, **kwargs): + super(GocryptfsMount, self).__init__(*args, **kwargs) + + # Workaround for some linters. + self.path = None + self.reverse = None + self.config_path = None + + self.setattrKwargs( + 'path', self.config.localGocryptfsPath(self.profile_id), **kwargs + ) + self.setattrKwargs('reverse', False, **kwargs) + self.setattrKwargs('password', None, store=False, **kwargs) + self.setattrKwargs('config_path', None, **kwargs) + + self.setDefaultArgs() + + self.mountproc = 'gocryptfs' + self.log_command = '%s: %s' % (self.mode, self.path) + self.symlink_subfolder = None + + def _mount(self): + """ + mount the service + """ + if self.password is None: + self.password = self.config.password( + self.parent, self.profile_id, self.mode + ) + thread = TempPasswordThread(self.password) + env = os.environ.copy() + env['ASKPASS_TEMP'] = thread.temp_file + + with thread.starter(): + gocryptfs = [ + self.mountproc, + '-extpass', + 'backintime-askpass', + '-quiet' + ] + if self.reverse: + gocryptfs += ['-reverse'] + + gocryptfs += [ + self.path, + self.currentMountpoint + ] + + logger.debug(f'Call mount command: {gocryptfs}', self) + + proc = subprocess.Popen( + gocryptfs, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + universal_newlines=True + ) + output = proc.communicate()[0] + + if proc.returncode: + msg = _('Unable to mount "{command}"').format( + command=' '.join(gocryptfs) + ) + raise MountException( + f'{msg}:\n\n{output}\n\nReturn code: {proc.returncode}' + ) + + def init_backend(self): + """ + init the cipher path + """ + if self.password is None: + self.password = self.config.password( + self.parent, self.profile_id, self.mode) + + # Dev note: See docstring in EncFS_mount._mount() for detailed + # description about the password thing. + thread = TempPasswordThread(self.password) + env = os.environ.copy() + env['ASKPASS_TEMP'] = thread.temp_file + + with thread.starter(): + gocryptfs = [ + self.mountproc, + '-extpass', + 'backintime-askpass'] + + gocryptfs.append('-init') + + gocryptfs.append(self.path) + + logger.debug( + f'Call command to create gocryptfs config file: {gocryptfs}', + self + ) + + proc = subprocess.Popen( + gocryptfs, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + universal_newlines=True) + + output = proc.communicate()[0] + + if proc.returncode: + msg = _('Unable to init encrypted path "{command}"').format( + command=' '.join(gocryptfs) + ) + raise MountException( + f'{msg}:\n\n{output}\n\nReturn code: {proc.returncode}' + ) + + def preMountCheck(self, first_run=False): + """ + check what ever conditions must be given for the mount + """ + self.checkFuse() + if first_run: + pass + return True + + def configFile(self): + """ + return gocryptfs config file + """ + f = 'gocryptfs.conf' + if self.config_path is None: + cfg = os.path.join(self.path, f) + else: + cfg = os.path.join(self.config_path, f) + return cfg + + def isConfigured(self): + """ + Check if `gocryptfs.conf` exists. + """ + conf = self.configFile() + ret = os.path.exists(conf) + + if ret: + logger.debug(f'Found gocryptfs config file in {conf}', self) + else: + logger.debug(f'No config in {conf}', self) + + return ret diff --git a/common/mount.py b/common/mount.py index d05d3f2d1..5605c898a 100644 --- a/common/mount.py +++ b/common/mount.py @@ -130,6 +130,9 @@ class Mount: tmp_mount (bool): If ``True`` mount to a temporary destination. parent (QWidget): Parent widget for QDialogs or ``None`` if there is no parent. + + Dev note (buhtz, 2026-01): Rename that class into something like + MountOrchestrator or MountManager. """ def __init__(self, @@ -375,6 +378,67 @@ def remount(self, new_profile_id, mode=None, hash_id=None, **kwargs): self.profile_id = new_profile_id return self.mount(mode = mode, **kwargs) + def isConfigured(self, mode = None, **kwargs): + """ + High-level check. Run :py:func:`MountControl.isConfigured` to check + if the backend is configured. + + Args: + mode (str): mode to use. One of 'local', 'ssh', + 'local_encfs' or 'ssh_encfs' + **kwargs: keyword arguments paste to low-level + :py:class:`MountControl` subclass backend + + Returns: + bool: ``True`` if backend is configured + """ + if mode is None: + mode = self.config.snapshotsMode(self.profile_id) + + if self.config.SNAPSHOT_MODES[mode][0] is None: + #mode doesn't need to mount + return True + else: + mounttools = self.config.SNAPSHOT_MODES[mode][0] + backend = mounttools(cfg = self.config, + profile_id = self.profile_id, + tmp_mount = self.tmp_mount, + mode = mode, + parent = self.parent, + **kwargs) + return backend.isConfigured() + + def init_backend(self, mode = None, **kwargs): + """ + High-level init. Run :py:func:`MountControl.init_backend` to initiate + the backend if not configured yet. + + Args: + mode (str): mode to use. One of 'local', 'ssh', + 'local_encfs' or 'ssh_encfs' + **kwargs: keyword arguments paste to low-level + :py:class:`MountControl` subclass backend + + Raises: + exceptions.MountException: if init_backend failed + """ + if mode is None: + mode = self.config.snapshotsMode(self.profile_id) + + if self.config.SNAPSHOT_MODES[mode][0] is None: + #mode doesn't need to mount + return True + else: + mounttools = self.config.SNAPSHOT_MODES[mode][0] + backend = mounttools(cfg = self.config, + profile_id = self.profile_id, + tmp_mount = self.tmp_mount, + mode = mode, + parent = self.parent, + **kwargs) + return backend.init_backend() + + class MountControl: """This is the low-level mount API. This should be subclassed by backends. @@ -413,6 +477,9 @@ class MountControl: ``ssh_encfs`` hash_collision (int): global value used to prevent hash collisions on mountpoints + + Dev note (buhtz, 2026-01): Rename that class into something like + MountController or MountBase """ def __init__(self, diff --git a/common/password_ipc.py b/common/password_ipc.py index 5fd91a7f9..90490dd97 100644 --- a/common/password_ipc.py +++ b/common/password_ipc.py @@ -15,6 +15,7 @@ import logger + class FIFO: """Inter-process communication (IPC) with named pipes using the first-in, first-out principle (FIFO). @@ -32,6 +33,7 @@ def delfifo(self): """Remove named pipe file.""" try: os.remove(self.fifo) + # TODO: Catch FileNotFoundError only except: pass @@ -121,9 +123,10 @@ def isFifo(self): class TempPasswordThread(threading.Thread): - """ - in case BIT is not configured yet provide password through temp FIFO - to backintime-askpass. + """Provide password through temp FIFO. + + In case BIT is not configured yet this provides a password through temp + FIFO to backintime-askpass. """ def __init__(self, string): diff --git a/common/test/test_lint.py b/common/test/test_lint.py index 17097a7b3..2220f84a0 100644 --- a/common/test/test_lint.py +++ b/common/test/test_lint.py @@ -51,8 +51,11 @@ 'daemon.py', 'event.py', 'encode.py', - 'languages.py', + # 'encfstools.py', + # 'gocryptfstools.py', 'inhibitsuspend.py', + 'languages.py', + # 'mount.py', 'schedule.py', 'shutdownagent.py', 'singleton.py', diff --git a/qt/manageprofiles/tab_general.py b/qt/manageprofiles/tab_general.py index 0215a39ee..dfe9e38d9 100644 --- a/qt/manageprofiles/tab_general.py +++ b/qt/manageprofiles/tab_general.py @@ -170,6 +170,9 @@ def __init__(self, parent): # noqa: PLR0915 self._group_mode_local_encfs = self._group_mode_local self._group_mode_ssh_encfs = self._group_mode_ssh + # gocryptfs + self._group_mode_local_gocrypt = self._group_mode_local + # password group_box = QGroupBox(self) self._group_password1 = group_box @@ -355,6 +358,10 @@ def load_values(self) -> Any: if self.mode == 'local_encfs': self._edit_backup_path.setText(self.config.localEncfsPath()) + # local_gocryptfs + if self.mode == 'local_gocryptfs': + self._edit_backup_path.setText(self.config.localGocryptfsPath()) + self._load_passwords() host, user, profile = self.config.hostUserProfile() @@ -412,6 +419,9 @@ def store_values(self) -> bool: # save local_encfs self.config.setLocalEncfsPath(self._edit_backup_path.text()) + # save local_gocryptfs + self.config.setLocalGocryptfsPath(self._edit_backup_path.text()) + # schedule success = self._wdg_schedule.store_values(self.config) @@ -475,6 +485,17 @@ def _do_alot_pre_mount_checking(self, mnt, mount_kwargs): # noqa: PLR0911 # pylint: disable=too-many-return-statements # preMountCheck + if not mnt.isConfigured( + mode=self.config.snapshotsMode(), **mount_kwargs): + + try: + mnt.init_backend( + mode=self.config.snapshotsMode(), **mount_kwargs) + + except MountException as ex: + messagebox.critical(self, str(ex)) + return False + try: # This will run several checks depending on the snapshots mode # used. Exceptions are raised if something goes wrong. On mode @@ -764,14 +785,14 @@ def handle_combo_modes_changed(self): self.mode = active_mode self._group_mode_local.setVisible( - active_mode in ('local', 'local_encfs')) + active_mode in ('local', 'local_encfs', 'local_gocryptfs')) self._group_mode_ssh.setVisible( active_mode in ('ssh', 'ssh_encfs')) # self._group_mode_local_encfs = self._group_mode_local # self._group_mode_sshEncfs = self._group_mode_ssh self._wdg_schedule.allow_udev( - active_mode in ('local', 'local_encfs')) + active_mode in ('local', 'local_encfs', 'local_gocryptfs')) if self.config.modeNeedPassword(active_mode):