from abc import ABC, abstractmethod import re import subprocess from .exceptions import RemoteExecException from . import repository from . import ssh_remote from . import _ssh_client def _parse_name(name): """ Parse a backup name into a remote specification. """ # split off the username if not '@' in name: raise ValueError('Invalid backup name: "%s", must be of format user@host') username, _, host = name.partition('@') port = 22 # overridden later if specified in name colons = host.count(':') if colons >= 2: # IPv6 literal, possibly with port m = re.match(r'\[(.+)\](:\d+)?', host, re.ASCII | re.IGNORECASE) if m is not None: # [literal]:port host, port = m.groups() elif colons == 1: # host:port host, _, port = host.partition(':') return ssh_remote.SSHRemote(host, port, username) class Target(ABC): name = None dirs = None excludes = None def __init__(self, name, dirs, excludes = None): if excludes is None: excludes = [] self.name = name self.dirs = dirs self.excludes = excludes @abstractmethod def save(self, data_dir): pass class TargetLocal(Target): def save(self, data_dir): cmd = ['bup', 'index', '--update', '--one-file-system'] cmd.extend(['--exclude=%s' % e for e in self.excludes]) cmd.extend(self.dirs) res_idx = subprocess.run(cmd, capture_output = True) cmd = ['bup', 'save', '-n', self.name] + self.dirs res_save = subprocess.run(cmd, capture_output = True) retcode = 0 output = b'' if res_idx.returncode != 0: retcode = res_idx.returncode output += res_idx.stderr + res_idx.stdout if res_save.returncode != 0: retcode = res_save.returncode output += res_save.stderr + res_save.stdout result = repository.StepResult(retcode, output) return result class TargetSSH(Target): _remote = None def __init__(self, name, dirs, excludes = None, remote = None): super().__init__(name, dirs, excludes) if remote is None: remote = _parse_name(name) if remote.proxy_remote is not None: raise NotImplementedError('Proxy remote not implemented') if remote.port != 22: raise NotImplementedError('Specifying port not implemented') self._remote = remote def save(self, data_dir): cmd = ['bup', 'on', '%s@%s' % (self._remote.username, self._remote.host), 'index', '--update', '--one-file-system'] cmd.extend(['--exclude=%s' % e for e in self.excludes]) cmd.extend(self.dirs) res_idx = subprocess.run(cmd, capture_output = True) cmd = ['bup', 'on', '%s@%s' %(self._remote.username, self._remote.host), 'save', '-n', self.name] + self.dirs res_save = subprocess.run(cmd, capture_output = True) retcode = 0 output = b'' if res_idx.returncode != 0: retcode = res_idx.returncode output += res_idx.stderr + res_idx.stdout if res_save.returncode != 0: retcode = res_save.returncode output += res_save.stderr + res_save.stdout result = repository.StepResult(retcode, output) return result def _paramiko_exec_cmd(client, cmd): res = client.exec_command(cmd) chan = res[0].channel out, err = res[1].read(), res[2].read() if chan.exit_status != 0: raise RemoteExecException('Error executing "%s"' % cmd, chan.exit_status, err + out) return out.decode('utf-8', errors = 'backslashreplace') class TargetSSHLXCLVM(TargetSSH): """ This target backs up an LXC container that lives on its own LVM logical volume. Requires root-capable login on the container's host. :param SSHRemote parent_remote: """ _parent_remote = None _lxc_username = None _lxc_containername = None def __init__(self, name, dirs, excludes = None, target_remote = None, parent_remote = None, lxc_username = None, lxc_containername = None, snapshot_size = '20G'): super().__init__(name, dirs, excludes, target_remote) if parent_remote is None: raise ValueError('parent_remote not specified') if lxc_username is None: lxc_username = parent_remote.usename self._parent_remote = parent_remote self._lxc_username = lxc_username self._lxc_containername = lxc_containername self._snapshot_size = snapshot_size def save(self, data_dir): with (_ssh_client.SSHConnection(self._parent_remote) as parent, _ssh_client.SSHConnection(self._remote) as container): # get the PID of the container's init cmd_template = 'su -s /bin/sh -c "{command}" %s' % self._lxc_username container_pid = _paramiko_exec_cmd(parent, cmd_template.format( command = 'lxc-info -H -p -n %s' % self._lxc_containername)).rstrip('\n') # get the LV/VG for the container's rootfs container_rootfs = _paramiko_exec_cmd(parent, cmd_template.format( command = 'lxc-info -H -c lxc.rootfs -n %s' % self._lxc_containername))\ .rstrip('\n')\ .translate({ord(' ') : r'\040', ord('\t') : r'\011', ord('\n') : r'\012', ord('\\') : r'\O134'}) mountline = _paramiko_exec_cmd(parent, 'grep "%s" /proc/mounts' % container_rootfs).rstrip('\n').split() if len(mountline) < 2 or mountline[1] != container_rootfs: raise RemoteExecException('Invalid mount line: %s' % mountline) lv_path = mountline[0] lv_name, vg_name = _paramiko_exec_cmd(parent, 'lvdisplay -C --noheadings -o lv_name,vg_name ' + lv_path)\ .strip().split() # we cannot trust any binaries located inside the container, since a # compromised container could use them to execute arbitrary code # with real root privileges, thus nullifying the point of # unprivileged containers) # so we now create a temporary # create a read-only snapshot snapshot_name = 'bupper_' + lv_name _paramiko_exec_cmd(parent, 'lvcreate --permission r --snapshot -L {size} -n {name} {origin}' .format(size = self._snapshot_size, name = snapshot_name, origin = lv_path)) # execute the backup try: print(container_pid, vg_name, lv_path, snapshot_name) finally: # delete the snapshot _paramiko_exec_cmd(parent, 'lvremove -f %s/%s' % (vg_name, snapshot_name))