diff options
Diffstat (limited to 'bupper/targets.py')
-rw-r--r-- | bupper/targets.py | 186 |
1 files changed, 186 insertions, 0 deletions
diff --git a/bupper/targets.py b/bupper/targets.py new file mode 100644 index 0000000..e56b71b --- /dev/null +++ b/bupper/targets.py @@ -0,0 +1,186 @@ + +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)) |