diff options
Diffstat (limited to 'bupper/targets.py')
-rw-r--r-- | bupper/targets.py | 184 |
1 files changed, 122 insertions, 62 deletions
diff --git a/bupper/targets.py b/bupper/targets.py index e56b71b..65fc22e 100644 --- a/bupper/targets.py +++ b/bupper/targets.py @@ -1,9 +1,14 @@ from abc import ABC, abstractmethod +import contextlib +import errno +import logging +import os.path import re +import socket import subprocess -from .exceptions import RemoteExecException +from .exceptions import BackupException, RemoteExecException from . import repository from . import ssh_remote from . import _ssh_client @@ -32,7 +37,13 @@ class Target(ABC): name = None dirs = None excludes = None - def __init__(self, name, dirs, excludes = None): + + _logger = None + + _index_opts = None + _save_opts = None + + def __init__(self, name, dirs, excludes = None, logger = None): if excludes is None: excludes = [] @@ -40,18 +51,23 @@ class Target(ABC): self.dirs = dirs self.excludes = excludes - @abstractmethod - def save(self, data_dir): - pass + if logger is None: + self._logger = logging.getLogger(self.name) + else: + self._logger = logger -class TargetLocal(Target): - def save(self, data_dir): - cmd = ['bup', 'index', '--update', '--one-file-system'] + self._index_opts = [] + self._save_opts = [] + + def _do_save(self, bup_exec): + cmd = bup_exec + ['index', '--update', '--one-file-system'] + self._index_opts cmd.extend(['--exclude=%s' % e for e in self.excludes]) cmd.extend(self.dirs) + self._logger.debug('Executing index command: ' + str(cmd)) res_idx = subprocess.run(cmd, capture_output = True) - cmd = ['bup', 'save', '-n', self.name] + self.dirs + cmd = bup_exec + ['save', '-n', self.name] + self._save_opts + self.dirs + self._logger.debug('Executing save command: ' + str(cmd)) res_save = subprocess.run(cmd, capture_output = True) retcode = 0 @@ -67,13 +83,19 @@ class TargetLocal(Target): return result + @abstractmethod + def save(self, data_dir): + pass + +class TargetLocal(Target): + def save(self, data_dir): + return self._do_save(['bup']) + 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: @@ -82,36 +104,10 @@ class TargetSSH(Target): 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 + super().__init__(name, dirs, excludes) -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') + def save(self, data_dir): + return self._do_save(['bup', 'on', '%s@%s' % (self._remote.username, self._remote.host)]) class TargetSSHLXCLVM(TargetSSH): """ @@ -124,11 +120,18 @@ class TargetSSHLXCLVM(TargetSSH): _lxc_username = None _lxc_containername = None + _container_mountpoint = '/mnt/bupper' + 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) + dirs_snapshot = [os.path.join(self._container_mountpoint, d) for d in dirs] + excludes_snapshot = None + if excludes is not None: + excludes_snapshot = [os.path.join(self._container_mountpoint, e) for e in excludes] + + super().__init__(name, dirs_snapshot, excludes_snapshot, target_remote) if parent_remote is None: raise ValueError('parent_remote not specified') @@ -140,47 +143,104 @@ class TargetSSHLXCLVM(TargetSSH): self._lxc_containername = lxc_containername self._snapshot_size = snapshot_size + def _paramiko_exec_cmd(self, client, cmd): + self._logger.debug('Client %s: executing command: %s' % (client, cmd)) + + res = client.exec_command(cmd) + + chan = res[0].channel + chan.settimeout(64) + try: + out, err = res[1].read(), res[2].read() + except socket.timeout as t: + raise RemoteExecException('Timeout waiting for command output', + errno.ETIMEDOUT, b'') from t + + chan.recv_exit_status() + if chan.exit_status != 0: + raise RemoteExecException('Error executing "%s"' % cmd, + chan.exit_status, err + out) + return out.decode('utf-8', errors = 'backslashreplace') + + def save(self, data_dir): - with (_ssh_client.SSHConnection(self._parent_remote) as parent, - _ssh_client.SSHConnection(self._remote) as container): + with contextlib.ExitStack() as stack: + parent = stack.enter_context(_ssh_client.SSHConnection(self._parent_remote)) + container = stack.enter_context(_ssh_client.SSHConnection(self._remote)) + + # create the mount directory + self._container_mountpoint = self._paramiko_exec_cmd(container, + 'mktemp -d --tmpdir bupper.XXXXXXXX').rstrip('\n') + if len(self._container_mountpoint) <= 1 or self._container_mountpoint[0] != '/': + raise BackupException('Unexpected mount directory: %s' % self._container_mountpoint) + stack.callback(lambda: self._paramiko_exec_cmd(container, + 'rmdir %s' % self._container_mountpoint)) + + self._save_opts.extend(['--strip-path', self._container_mountpoint]) + # 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( + container_pid = self._paramiko_exec_cmd(parent, cmd_template.format( command = 'lxc-info -H -p -n %s' % self._lxc_containername)).rstrip('\n') + if not re.fullmatch('[0-9]+', container_pid): + raise BackupException('Invalid container PID: %s' % container_pid) # 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' % + container_rootfs = self._paramiko_exec_cmd(parent, cmd_template.format( + command = 'lxc-info -H -c lxc.rootfs.path -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' % + .translate({ord(' ') : r'\040', ord('\t') : r'\011', + ord('\n') : r'\012', ord('\\') : r'\0134'}) + if len(container_rootfs) <= 1 or container_rootfs[0] != '/': + raise BackupException('Unxpected container rootfs directory: %s' % container_rootfs) + + mountline = self._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) + raise BackupException('Invalid mount line: %s' % mountline) lv_path = mountline[0] - lv_name, vg_name = _paramiko_exec_cmd(parent, + lv_fstype = mountline[2] + if len(lv_path) <= 1 or lv_path[0] != '/' or len(lv_fstype) < 1: + raise BackupException('Unexpected LV path/FS type: %s\t%s' % (lv_path, lv_fstype)) + + lv_name, vg_name = self._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 - + if len(lv_name) < 1 or len(vg_name) < 1: + raise BackupException('Unexpected LV/VG name: %s\t%s' % (lv_name, vg_name)) # create a read-only snapshot snapshot_name = 'bupper_' + lv_name - _paramiko_exec_cmd(parent, + self._paramiko_exec_cmd(parent, 'lvcreate --permission r --snapshot -L {size} -n {name} {origin}' .format(size = self._snapshot_size, name = snapshot_name, origin = lv_path)) + stack.callback(lambda: self._paramiko_exec_cmd(parent, + 'lvremove -f %s/%s' % (vg_name, snapshot_name))) # execute the backup + # wait for the new node to be created + self._paramiko_exec_cmd(parent, 'udevadm settle') + + # 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 ship a special tool, 'nsmount', which has to be + # installed on the parent, to mount the snapshot into the + # container mount namespace + self._paramiko_exec_cmd(parent, + 'nsmount m {pid} {mountpoint} {devpath} {fstype}'.format( + pid = container_pid, mountpoint = self._container_mountpoint, + devpath = '/dev/%s/%s' % (vg_name, snapshot_name), + fstype = lv_fstype)) + try: - print(container_pid, vg_name, lv_path, snapshot_name) + ret = super().save(data_dir) finally: - # delete the snapshot - _paramiko_exec_cmd(parent, 'lvremove -f %s/%s' % (vg_name, snapshot_name)) + self._paramiko_exec_cmd(parent, + 'nsmount u {pid} {mountpoint}'.format( + pid = container_pid, mountpoint = self._container_mountpoint)) + + return ret |