summaryrefslogtreecommitdiff
path: root/bupper
diff options
context:
space:
mode:
Diffstat (limited to 'bupper')
-rw-r--r--bupper/__init__.py0
-rw-r--r--bupper/_ssh_client.py45
-rw-r--r--bupper/_sshfp_policy.py61
-rw-r--r--bupper/exceptions.py11
-rw-r--r--bupper/repository.py81
-rw-r--r--bupper/ssh_remote.py19
-rw-r--r--bupper/targets.py186
7 files changed, 403 insertions, 0 deletions
diff --git a/bupper/__init__.py b/bupper/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/bupper/__init__.py
diff --git a/bupper/_ssh_client.py b/bupper/_ssh_client.py
new file mode 100644
index 0000000..f1c9e23
--- /dev/null
+++ b/bupper/_ssh_client.py
@@ -0,0 +1,45 @@
+import paramiko.client as pmc
+
+from ._sshfp_policy import SSHFPPolicy
+
+class SSHConnection:
+ """
+ An SSH client connection to a remote server, with support for a proxy "jump"
+ host, like OpenSSH's 'ssh -J'. Uses only SSHFP for host key verification.
+
+ May be used as a context manager.
+
+ :param SSHRemote remote: Remote host to connect to.
+ """
+ _proxy_conn = None
+ _client = None
+
+ def __init__(self, remote):
+ sock = None
+ if remote.proxy_remote is not None:
+ self._proxy_conn = SSHConnection(remote.proxy_remote)
+ t = self._proxy_conn.get_transport()
+ sock = t.open_channel('direct-tcpip', (remote.host, remote.port), ('localhost', 0))
+
+ self._client = pmc.SSHClient()
+ self._client.set_missing_host_key_policy(SSHFPPolicy())
+ self._client.connect(remote.host, remote.port, remote.username,
+ sock = sock)
+
+ def close(self):
+ if self._client:
+ self._client.close()
+ self._client = None
+ if self._proxy_conn:
+ self._proxy_conn.close()
+ self._proxy_conn = None
+
+ def exec_command(self, *args, **kwargs):
+ return self._client.exec_command(*args, **kwargs)
+ def get_transport(self):
+ return self._client.get_transport()
+
+ def __enter__(self):
+ return self
+ def __exit__(self, exc_type, exc_value, traceback):
+ self.close()
diff --git a/bupper/_sshfp_policy.py b/bupper/_sshfp_policy.py
new file mode 100644
index 0000000..8b7c2c7
--- /dev/null
+++ b/bupper/_sshfp_policy.py
@@ -0,0 +1,61 @@
+import hashlib
+
+from dns import flags, rdatatype, resolver as dnsres
+
+from paramiko.client import MissingHostKeyPolicy
+from paramiko.common import DEBUG
+
+_key_algorithms = {
+ 'ssh-rsa' : '1',
+ 'ssh-dss' : '2',
+ 'ecdsa-sha2-nistp256' : '3',
+ 'ecdsa-sha2-nistp384' : '3',
+ 'ecdsa-sha2-nistp521' : '3',
+ 'ssh-ed25519' : '4',
+}
+
+_hash_funcs = {
+ '1' : hashlib.sha1,
+ '2' : hashlib.sha256,
+}
+
+class SSHFPPolicy(MissingHostKeyPolicy):
+ '''Checks for a matching SSHFP RR'''
+ def __init__(self, resolver = None):
+ if resolver is None:
+ resolver = dnsres.Resolver()
+ resolver.use_edns(0, flags.DO, 1280)
+
+ self._resolver = resolver
+
+ def missing_host_key(self, client, hostname, key):
+ try:
+ key_alg = _key_algorithms[key.get_name()]
+ except KeyError:
+ raise Exception('Unsupported key type for SSHFP: %s' % key.get_name())
+
+ try:
+ resp = self._resolver.query(hostname, 'SSHFP')
+ except dnsres.NoAnswer:
+ raise Exception('Could not obtain SSHFP records for host: %s' % hostname)
+
+ if not resp.response.flags & flags.AD:
+ raise Exception('Answer does not have a valid DNSSEC signature')
+
+ for item in resp:
+ try:
+ alg, fg_type, fg = item.to_text().split()
+ except ValueError:
+ raise Exception('Invalid SSHFP record format: %s' % item.to_text())
+
+ if alg != key_alg:
+ continue
+
+ if not fg_type in _hash_funcs:
+ continue
+
+ fg_expect = _hash_funcs[fg_type](key.asbytes()).hexdigest()
+ if fg_expect == fg:
+ client._log(DEBUG, 'Found valid SSHFP record for host %s' % hostname)
+ return
+ raise Exception('No matching SSHFP records found')
diff --git a/bupper/exceptions.py b/bupper/exceptions.py
new file mode 100644
index 0000000..8944fe9
--- /dev/null
+++ b/bupper/exceptions.py
@@ -0,0 +1,11 @@
+class RemoteExecException(Exception):
+ retcode = None
+ output = None
+ def __init__(self, explanation, retcode, output):
+ super().__init__(explanation)
+ self.retcode = retcode
+ self.output = output
+
+ def __str__(self):
+ return (super().__str__() +
+ ';%d: %s' % (self.retcode, self.output.decode('utf-8', errors = 'backslashreplace')))
diff --git a/bupper/repository.py b/bupper/repository.py
new file mode 100644
index 0000000..98a4984
--- /dev/null
+++ b/bupper/repository.py
@@ -0,0 +1,81 @@
+import fcntl
+import os
+import os.path
+import subprocess
+
+class StepResult:
+ retcode = None
+ output = None
+ def __init__(self, retcode = 0, output = None):
+ self.retcode = retcode
+ self.output = output
+
+class BackupResult:
+ target_results = None
+ par2_result = None
+
+ def __init__(self):
+ self.target_results = []
+ self.par2_result = StepResult()
+
+ @property
+ def all_ok(self):
+ return (all(map(lambda tgtres: tgtres.retcode == 0, self.target_results)) and
+ self.par2_result.retcode == 0)
+
+
+class Repo:
+ """
+ A single Bup repository into which the data will be backed up, plus a
+ separate directory for extra runtime data.
+
+ :param str bup_dir: path to the bup repository, defaults to BUP_DIR or ~/.bup
+ :param str data_dir: path to the directory for storing the runtime data,
+ defaults to ~/.local/var/bupper
+ """
+ bup_dir = None
+ data_dir = None
+ lock_name = 'lock'
+
+ def __init__(self, bup_dir = None, data_dir = None):
+ if bup_dir is None:
+ if 'BUP_DIR' in os.environ:
+ bup_dir = os.environ['BUP_DIR']
+ else:
+ bup_dir = os.path.expanduser('~/.bup')
+
+ if data_dir is None:
+ data_dir = os.path.expanduser('~/.local/var/bupper/')
+
+ # create the data dir, if it does not already exist
+ os.makedirs(data_dir, 0o700, exist_ok = True)
+
+ self.bup_dir = bup_dir
+ self.data_dir = data_dir
+
+ def backup(self, tgts, gen_par2 = True):
+ """
+ Backup the supplied targets.
+
+ :param list of Target tgts: List of targets to back up.
+ :param bool gen_par2: Whether to generate par2 recovery information
+ after the backup concludes'
+ """
+ with open(os.path.join(self.data_dir, self.lock_name), 'w') as lockfile:
+ result = BackupResult()
+
+ fcntl.lockf(lockfile, fcntl.LOCK_EX)
+ try:
+ for tgt in tgts:
+ res = tgt.save(self.data_dir)
+ result.target_results.append(res)
+
+ if gen_par2:
+ res = subprocess.run(['bup', 'fsck', '-g'],
+ capture_output = True)
+ result.par2_result = StepResult(res.returncode,
+ res.stderr + res.stdout)
+ finally:
+ fcntl.lockf(lockfile, fcntl.LOCK_UN)
+
+ return result
diff --git a/bupper/ssh_remote.py b/bupper/ssh_remote.py
new file mode 100644
index 0000000..89e8f17
--- /dev/null
+++ b/bupper/ssh_remote.py
@@ -0,0 +1,19 @@
+class SSHRemote:
+ """
+ Specification of an SSH remote host, represented by a combination of host,
+ port and username, plus an optional proxy remote.
+ :param str host:
+ :param int port:
+ :param str username:
+ :param SSHRemote proxy_remote: proxy through which the connection should be
+ tunnelled
+ """
+ host = None
+ port = None
+ username = None
+ proxy_remote = None
+ def __init__(self, host, port, username, proxy_remote = None):
+ self.host = host
+ self.port = port
+ self.username = username
+ self.proxy_remote = proxy_remote
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))