diff --git a/Makefile b/Makefile index 24ee7b1e..f58172b2 100644 --- a/Makefile +++ b/Makefile @@ -12,7 +12,7 @@ ADMIN_API_METHODS_SIMPLE = \ admin.Events \ admin.backup.Execute \ admin.backup.Info \ - admin.backup.Restore \ + admin.backup.Cancel \ admin.label.Create \ admin.label.Get \ admin.label.List \ diff --git a/ci/requirements.txt b/ci/requirements.txt index 7ec982f5..001a61d1 100644 --- a/ci/requirements.txt +++ b/ci/requirements.txt @@ -8,3 +8,4 @@ lxml pylint sphinx pydbus +PyYAML diff --git a/qubes/api/admin.py b/qubes/api/admin.py index 1f2659c4..c090c730 100644 --- a/qubes/api/admin.py +++ b/qubes/api/admin.py @@ -23,17 +23,24 @@ Qubes OS Management API ''' import asyncio -import string +import functools import itertools -import pkg_resources +import os +import string + import libvirt +import pkg_resources +import yaml import qubes.api +import qubes.backup +import qubes.config import qubes.devices import qubes.firewall import qubes.storage import qubes.utils import qubes.vm +import qubes.vm.adminvm import qubes.vm.qubesvm @@ -1093,3 +1100,152 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI): self.fire_event_for_permission() self.dest.fire_event('firewall-changed') + + @asyncio.coroutine + def _load_backup_profile(self, profile_name, skip_passphrase=False): + '''Load backup profile and return :py:class:`qubes.backup.Backup` + instance + + :param profile_name: name of the profile + :param skip_passphrase: do not load passphrase - only backup summary + can be retrieved when this option is in use + ''' + profile_path = os.path.join( + qubes.config.backup_profile_dir, profile_name + '.conf') + + with open(profile_path) as profile_file: + profile_data = yaml.safe_load(profile_file) + + try: + dest_vm = profile_data['destination_vm'] + dest_path = profile_data['destination_path'] + include_vms = profile_data['include'] + exclude_vms = profile_data.get('exclude', []) + compression = profile_data.get('compression', True) + except KeyError as err: + raise qubes.exc.QubesException( + 'Invalid backup profile - missing {}'.format(err)) + + try: + dest_vm = self.app.domains[dest_vm] + except KeyError: + raise qubes.exc.QubesException( + 'Invalid destination_vm specified in backup profile') + + if isinstance(dest_vm, qubes.vm.adminvm.AdminVM): + dest_vm = None + + if skip_passphrase: + passphrase = None + elif 'passphrase_text' in profile_data: + passphrase = profile_data['passphrase_text'] + elif 'passphrase_vm' in profile_data: + passphrase_vm_name = profile_data['passphrase_vm'] + try: + passphrase_vm = self.app.domains[passphrase_vm_name] + except KeyError: + raise qubes.exc.QubesException( + 'Invalid backup profile - invalid passphrase_vm') + # TODO .decode()? + passphrase, _ = yield from passphrase_vm.run_service_for_stdio( + 'qubes.BackupPassphrase+' + self.arg) + else: + raise qubes.exc.QubesException( + 'Invalid backup profile - you need to ' + 'specify passphrase_text or passphrase_vm') + + # handle include + vms_to_backup = set(vm for vm in self.app.domains + if any(qubes.utils.match_vm_name_with_special(vm, name) + for name in include_vms)) + + # handle exclude + vms_to_backup.difference_update(vm for vm in self.app.domains + if any(qubes.utils.match_vm_name_with_special(vm, name) + for name in exclude_vms)) + + kwargs = { + 'target_vm': dest_vm, + 'target_dir': dest_path, + 'compressed': bool(compression), + 'passphrase': passphrase, + } + if isinstance(compression, str): + kwargs['compression_filter'] = compression + backup = qubes.backup.Backup(self.app, vms_to_backup, **kwargs) + return backup + + def _backup_progress_callback(self, profile_name, progress): + self.app.fire_event('backup-progress', backup_profile=profile_name, + progress=progress) + + @qubes.api.method('admin.backup.Execute', no_payload=True, + scope='global', read=True, execute=True) + @asyncio.coroutine + def backup_execute(self): + assert self.dest.name == 'dom0' + assert self.arg + assert '/' not in self.arg + + self.fire_event_for_permission() + + profile_path = os.path.join(qubes.config.backup_profile_dir, + self.arg + '.conf') + if not os.path.exists(profile_path): + raise qubes.api.PermissionDenied( + 'Backup profile {} does not exist'.format(self.arg)) + + if not hasattr(self.app, 'api_admin_running_backups'): + self.app.api_admin_running_backups = {} + + backup = yield from self._load_backup_profile(self.arg) + backup.progress_callback = functools.partial( + self._backup_progress_callback, self.arg) + + # forbid running the same backup operation twice at the time + assert self.arg not in self.app.api_admin_running_backups + + backup_task = asyncio.ensure_future(backup.backup_do()) + self.app.api_admin_running_backups[self.arg] = backup_task + try: + yield from backup_task + finally: + del self.app.api_admin_running_backups[self.arg] + + @qubes.api.method('admin.backup.Cancel', no_payload=True, + scope='global', execute=True) + @asyncio.coroutine + def backup_cancel(self): + assert self.dest.name == 'dom0' + assert self.arg + assert '/' not in self.arg + + self.fire_event_for_permission() + + if not hasattr(self.app, 'api_admin_running_backups'): + self.app.api_admin_running_backups = {} + + if self.arg not in self.app.api_admin_running_backups: + raise qubes.exc.QubesException('Backup operation not running') + + self.app.api_admin_running_backups[self.arg].cancel() + + @qubes.api.method('admin.backup.Info', no_payload=True, + scope='local', read=True) + @asyncio.coroutine + def backup_info(self): + assert self.dest.name == 'dom0' + assert self.arg + assert '/' not in self.arg + + self.fire_event_for_permission() + + profile_path = os.path.join(qubes.config.backup_profile_dir, + self.arg + '.conf') + if not os.path.exists(profile_path): + raise qubes.api.PermissionDenied( + 'Backup profile {} does not exist'.format(self.arg)) + + backup = yield from self._load_backup_profile(self.arg, + skip_passphrase=True) + return backup.get_backup_summary() diff --git a/qubes/config.py b/qubes/config.py index 8bc3864e..50a22a35 100644 --- a/qubes/config.py +++ b/qubes/config.py @@ -106,3 +106,6 @@ max_dispid = 10000 #: built-in standard labels, if creating new one, allocate them above this # number, at least until label index is removed from API max_default_label = 8 + +#: profiles for admin.backup.* calls +backup_profile_dir = '/etc/qubes/backup' diff --git a/qubes/utils.py b/qubes/utils.py index 1bd1fadb..2ee25740 100644 --- a/qubes/utils.py +++ b/qubes/utils.py @@ -176,3 +176,12 @@ def systemd_notify(): sock.connect(nofity_socket) sock.sendall(b'READY=1') sock.close() + +def match_vm_name_with_special(vm, name): + '''Check if *vm* matches given name, which may be specified as $tag:... + or $type:...''' + if name.startswith('$tag:'): + return name[len('$tag:'):] in vm.tags + elif name.startswith('$type:'): + return name[len('$type:'):] == vm.__class__.__name__ + return name == vm.name diff --git a/rpm_spec/core-dom0.spec b/rpm_spec/core-dom0.spec index b1b36492..f32f06c4 100644 --- a/rpm_spec/core-dom0.spec +++ b/rpm_spec/core-dom0.spec @@ -65,6 +65,7 @@ BuildRequires: python3-sphinx BuildRequires: python3-lxml BuildRequires: libvirt-python3 BuildRequires: python3-dbus +BuildRequires: python3-PyYAML Requires(post): systemd-units Requires(preun): systemd-units @@ -78,6 +79,7 @@ Requires: python3-lxml Requires: python3-pydbus Requires: python3-qubesdb Requires: python3-setuptools +Requires: python3-PyYAML Requires: python3-xen Requires: libvirt-python3