diff --git a/doc/qubes-vm/remotevm.rst b/doc/qubes-vm/remotevm.rst new file mode 100644 index 000000000..93dbdcb54 --- /dev/null +++ b/doc/qubes-vm/remotevm.rst @@ -0,0 +1,8 @@ +:py:mod:`qubes.vm.remotevm` -- Remote VM +========================================== + +.. automodule:: qubes.vm.remotevm + :members: + :show-inheritance: + +.. vim: ts=3 sw=3 et diff --git a/qubes/api/admin.py b/qubes/api/admin.py index 4dcfc776b..a4249bad4 100644 --- a/qubes/api/admin.py +++ b/qubes/api/admin.py @@ -370,7 +370,11 @@ def _property_reset(self, dest): async def vm_volume_list(self): self.enforce(not self.arg) - volume_names = self.fire_event_for_filter(self.dest.volumes.keys()) + volume_names = ( + self.fire_event_for_filter(self.dest.volumes.keys()) + if isinstance(self.dest, qubes.vm.qubesvm.QubesVM) + else [] + ) return "".join("{}\n".format(name) for name in volume_names) @qubes.api.method( @@ -1262,7 +1266,8 @@ async def _vm_create( vm.tags.add("created-by-" + str(self.src)) try: - await vm.create_on_disk(pool=pool, pools=pools) + if isinstance(vm, qubes.vm.qubesvm.QubesVM): + await vm.create_on_disk(pool=pool, pools=pools) except: del self.app.domains[vm] raise @@ -1310,7 +1315,10 @@ async def vm_remove(self): if not self.dest.is_halted(): raise qubes.exc.QubesVMNotHaltedError(self.dest) - if self.dest.installed_by_rpm: + if ( + isinstance(self.dest, qubes.vm.qubesvm.QubesVM) + and self.dest.installed_by_rpm + ): raise qubes.exc.QubesVMInUseError( self.dest, "VM installed by package manager: " + self.dest.name, diff --git a/qubes/api/internal.py b/qubes/api/internal.py index 9d1c686d0..8513b36b6 100644 --- a/qubes/api/internal.py +++ b/qubes/api/internal.py @@ -49,6 +49,10 @@ class SystemInfoCache: "property-reset:icon", "property-set:guivm", "property-reset:guivm", + "property-set:relayvm", + "property-reset:relayvm", + "property-set:transport_rpc", + "property-reset:transport_rpc", # technically not changeable, but keep for consistency "property-set:uuid", "property-reset:uuid", @@ -125,6 +129,16 @@ def get_system_info(cls, app): if getattr(domain, "guivm", None) else None ), + "relayvm": ( + domain.relayvm.name + if getattr(domain, "relayvm", None) + else None + ), + "transport_rpc": ( + domain.transport_rpc + if getattr(domain, "transport_rpc", None) + else None + ), "power_state": domain.get_power_state(), "uuid": str(domain.uuid), } diff --git a/qubes/app.py b/qubes/app.py index 8296dbde2..a3864189e 100644 --- a/qubes/app.py +++ b/qubes/app.py @@ -503,7 +503,7 @@ def vms(self): def add(self, value, _enable_events=True): """Add VM to collection - :param qubes.vm.LocalVM value: VM to add + :param qubes.vm.BaseVM value: VM to add :param _enable_events: :raises TypeError: when value is of wrong type :raises ValueError: when there is already VM which has equal ``qid`` @@ -511,7 +511,7 @@ def add(self, value, _enable_events=True): # this violates duck typing, but is needed # for VMProperty to function correctly - if not isinstance(value, qubes.vm.LocalVM): + if not isinstance(value, qubes.vm.BaseVM): raise TypeError( "{} holds only LocalVM instances".format( self.__class__.__name__ @@ -545,7 +545,7 @@ def __getitem__(self, key): return vm raise KeyError(key) - if isinstance(key, qubes.vm.LocalVM): + if isinstance(key, qubes.vm.BaseVM): key = key.uuid if isinstance(key, uuid.UUID): @@ -559,10 +559,11 @@ def __getitem__(self, key): def __delitem__(self, key): vm = self[key] - if not vm.is_halted(): + if isinstance(vm, qubes.vm.qubesvm.QubesVM) and not vm.is_halted(): raise qubes.exc.QubesVMNotHaltedError(vm) self.app.fire_event("domain-pre-delete", pre_event=True, vm=vm) - vm.libvirt_undefine() + if isinstance(vm, qubes.vm.qubesvm.QubesVM): + vm.libvirt_undefine() del self._dict[vm.qid] self.app.fire_event("domain-delete", vm=vm) if getattr(vm, "dispid", None): @@ -1654,8 +1655,10 @@ def on_domain_pre_deleted(self, event, vm): "see 'journalctl -u qubesd -e' in dom0 for " "details".format(vm.name), ) - - assignments = vm.get_provided_assignments() + if isinstance(vm, qubes.vm.qubesvm.QubesVM): + assignments = vm.get_provided_assignments() + else: + assignments = [] if assignments: desc = ", ".join(assignment.port_id for assignment in assignments) raise qubes.exc.QubesVMInUseError( diff --git a/qubes/ext/block.py b/qubes/ext/block.py index 244e62371..c704d0246 100644 --- a/qubes/ext/block.py +++ b/qubes/ext/block.py @@ -31,10 +31,11 @@ import qubes.device_protocol import qubes.devices import qubes.ext +from qubes.devices import Port from qubes.ext import utils from qubes.storage import Storage from qubes.vm.qubesvm import QubesVM -from qubes.devices import Port +from qubes.vm.remotevm import RemoteVM name_re = re.compile(r"\A[a-z0-9-]{1,12}\Z") device_re = re.compile(r"\A[a-z0-9/-]{1,64}\Z") @@ -346,7 +347,7 @@ def on_qdb_change(self, vm, event, path): def get_device_attachments(vm_): result = {} for vm in vm_.app.domains: - if not vm.is_running(): + if not vm.is_running() or isinstance(vm, RemoteVM): continue if vm.app.vmm.offline_mode: diff --git a/qubes/ext/relay.py b/qubes/ext/relay.py new file mode 100644 index 000000000..d4f5b5833 --- /dev/null +++ b/qubes/ext/relay.py @@ -0,0 +1,65 @@ +# +# The Qubes OS Project, https://www.qubes-os.org/ +# +# Copyright (C) 2024 Frédéric Pierret +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, see . +# + +import qubes.ext +import qubes.vm.remotevm + + +class Relay(qubes.ext.Extension): + # pylint: disable=unused-argument + @qubes.ext.handler("domain-init", "domain-load") + def on_domain_init_load(self, vm, event): + if ( + getattr(vm, "relayvm", None) + and "relayvm-" + vm.relayvm.name not in vm.tags + ): + self.on_property_set(vm, event, name="relayvm", newvalue=vm.relayvm) + + @qubes.ext.handler("domain-start") + def on_domain_start(self, vm, event, **kwargs): + if not vm.untrusted_qdb: + return + for domain in vm.app.domains: + if getattr(domain, "relayvm", None) == vm: + vm.untrusted_qdb.write( + f"/remote/{domain.name}", domain.remote_name or domain.name + ) + + @qubes.ext.handler("property-reset:relayvm", vm=qubes.vm.remotevm.RemoteVM) + def on_property_reset(self, subject, event, name, oldvalue=None): + newvalue = getattr(subject, "relayvm", None) + self.on_property_set(subject, event, name, newvalue, oldvalue) + + @qubes.ext.handler("property-set:relayvm", vm=qubes.vm.remotevm.RemoteVM) + def on_property_set(self, subject, event, name, newvalue, oldvalue=None): + # Clean other 'relayvm-XXX' tags. + # qrexec-client-vm can connect to only one domain + tags_list = list(subject.tags) + for tag in tags_list: + if tag.startswith("relayvm-"): + subject.tags.remove(tag) + + if newvalue: + relayvm_tag = "relayvm-" + newvalue.name + subject.tags.add(relayvm_tag) + if newvalue.untrusted_qdb: + remote_name = subject.remote_name or subject.name + newvalue.untrusted_qdb.write( + f"/remote/{subject.name}", remote_name + ) diff --git a/qubes/tests/api_internal.py b/qubes/tests/api_internal.py index 1c8a20b58..ec1919261 100644 --- a/qubes/tests/api_internal.py +++ b/qubes/tests/api_internal.py @@ -37,6 +37,8 @@ async def coro_f(*args, **kwargs): class TC_00_API_Misc(qubes.tests.QubesTestCase): + maxDiff = None + def setUp(self): super().setUp() self.app = mock.NonCallableMock() @@ -195,6 +197,8 @@ def test_010_get_system_info(self): "icon": "icon-dom0", "guivm": None, "power_state": "Running", + "relayvm": None, + "transport_rpc": None, "uuid": "00000000-0000-0000-0000-000000000000", }, "vm": { @@ -205,6 +209,8 @@ def test_010_get_system_info(self): "icon": "icon-vm", "guivm": "vm", "power_state": "Halted", + "relayvm": None, + "transport_rpc": None, "uuid": str(TEST_UUID), }, } diff --git a/qubes/tests/app.py b/qubes/tests/app.py index fcd2a8e8a..f64306fc6 100644 --- a/qubes/tests/app.py +++ b/qubes/tests/app.py @@ -21,6 +21,7 @@ # import os +import unittest import unittest.mock as mock import lxml.etree @@ -35,6 +36,8 @@ import logging import time +from qubes.tests.vm.qubesvm import TestQubesDB + class TestApp(qubes.tests.TestEmitter): pass @@ -915,6 +918,87 @@ class MyTestHolder(qubes.tests.TestEmitter, qubes.PropertyHolder): self.assertNotIn("audiovm-sys-audio", appvm.tags) self.assertNotIn("audiovm-", appvm.tags) + def test_116_remotevm_add_and_remove(self): + remotevm1 = self.app.add_new_vm( + "RemoteVM", name="remote-vm1", label="blue" + ) + self.app.add_new_vm("RemoteVM", name="remote-vm2", label="gray") + self.app.add_new_vm( + "AppVM", + name="test-vm", + template=self.template, + label="red", + ) + + assert remotevm1 in self.app.domains + del self.app.domains["remote-vm1"] + + self.assertCountEqual( + {d.name for d in self.app.domains}, + {"dom0", "test-template", "test-vm", "remote-vm2"}, + ) + + def test_117_remotevm_status(self): + remotevm1 = self.app.add_new_vm( + "RemoteVM", name="remote-vm1", label="blue" + ) + assert [ + remotevm1.get_power_state(), + remotevm1.get_cputime(), + remotevm1.get_mem(), + ] == ["Running", 0, 0] + + @unittest.mock.patch("qubes.vm.qubesvm.QubesVM.untrusted_qdb") + def test_118_remotevm_set_relayvm(self, mock_qubesdb): + class MyTestHolder(qubes.tests.TestEmitter, qubes.PropertyHolder): + relayvm = qubes.property("relayvm") + transport_rpc = qubes.property("transport_rpc") + + localrelay = self.app.add_new_vm( + "AppVM", + name="local-relay", + template=self.template, + label="red", + ) + # add QDB to localrelay + test_qubesdb = TestQubesDB() + mock_qubesdb.write.side_effect = test_qubesdb.write + mock_qubesdb.rm.side_effect = test_qubesdb.rm + localrelay.untrusted_qdb = test_qubesdb + + remotevm = self.app.add_new_vm( + "RemoteVM", name="remote-vm", label="blue" + ) + remotevm.remote_name = "myawesomevm" + + holder = MyTestHolder(None) + holder.relayvm = "local-relay" + holder.transport_rpc = "qubesair.SSHProxy" + self.assertEqual(holder.relayvm, "local-relay") + self.assertEqual(holder.transport_rpc, "qubesair.SSHProxy") + + self.assertEventFired( + holder, + "property-set:relayvm", + kwargs={"name": "relayvm", "newvalue": "local-relay"}, + ) + + self.assertEventFired( + holder, + "property-set:transport_rpc", + kwargs={"name": "transport_rpc", "newvalue": "qubesair.SSHProxy"}, + ) + + # Set RelayVM + remotevm.relayvm = localrelay + self.assertIn("relayvm-local-relay", remotevm.tags) + + # Read QDB path + self.assertEqual( + localrelay.untrusted_qdb.read("/remote/remote-vm"), + remotevm.remote_name, + ) + def test_200_remove_template(self): appvm = self.app.add_new_vm( "AppVM", name="test-vm", template=self.template, label="red" diff --git a/qubes/vm/__init__.py b/qubes/vm/__init__.py index e5ee67026..513334a7a 100644 --- a/qubes/vm/__init__.py +++ b/qubes/vm/__init__.py @@ -269,6 +269,21 @@ def __init__(self, app, xml, features=None, tags=None, **kwargs): if hasattr(self, "name"): self.init_log() + #: operations which shouldn't happen simultaneously with qube startup + # (including another startup of the same qube) + self.startup_lock = asyncio.Lock() + + def __str__(self): + return self.name + + def __hash__(self): + return self.qid + + def __lt__(self, other): + if not isinstance(other, qubes.vm.BaseVM): + return NotImplemented + return self.name < other.name + @qubes.stateless_property def klass(self): """Domain class name""" @@ -342,6 +357,13 @@ def __repr__(self): " ".join(proprepr), ) + @qubes.events.handler("domain-init", "domain-load") + def on_domain_init_loaded(self, event): + # pylint: disable=unused-argument + if not hasattr(self, "uuid"): + # pylint: disable=attribute-defined-outside-init + self.uuid = uuid.uuid4() + class LocalVM(BaseVM): """Base class for all local VMs @@ -478,6 +500,8 @@ def get_provided_assignments( for domain in self.app.domains: if domain == self: continue + if getattr(domain, "klass") == "RemoteVM": + continue for device_collection in domain.devices.values(): for assignment in device_collection.get_assigned_devices( required_only diff --git a/qubes/vm/qubesvm.py b/qubes/vm/qubesvm.py index 3821f9e4c..8eb69b575 100644 --- a/qubes/vm/qubesvm.py +++ b/qubes/vm/qubesvm.py @@ -30,7 +30,6 @@ import shutil import string import subprocess -import uuid import libvirt # pylint: disable=import-error import lxml.etree @@ -1142,10 +1141,6 @@ def __init__(self, app, xml, volume_config=None, **kwargs): # will be initialized after loading all the properties - #: operations which shouldn't happen simultaneously with qube startup - # (including another startup of the same qube) - self.startup_lock = asyncio.Lock() - # fire hooks if xml is None: self.events_enabled = True @@ -1159,15 +1154,10 @@ def close(self): self._libvirt_domain = None super().close() - def __hash__(self): - return self.qid - def __lt__(self, other): - if not isinstance(other, qubes.vm.LocalVM): - return NotImplemented if isinstance(other, qubes.vm.adminvm.AdminVM): return False - return self.name < other.name + return super().__lt__(other) def __xml__(self): # pylint: disable=no-member @@ -1188,10 +1178,7 @@ def __xml__(self): @qubes.events.handler("domain-init", "domain-load") def on_domain_init_loaded(self, event): - # pylint: disable=unused-argument - if not hasattr(self, "uuid"): - # pylint: disable=attribute-defined-outside-init - self.uuid = uuid.uuid4() + super().on_domain_init_loaded(event) # Initialize VM image storage class; # it might be already initialized by a recursive call from a child VM diff --git a/qubes/vm/remotevm.py b/qubes/vm/remotevm.py new file mode 100644 index 000000000..3dcb851ff --- /dev/null +++ b/qubes/vm/remotevm.py @@ -0,0 +1,91 @@ +# The Qubes OS Project, http://www.qubes-os.org +# +# Copyright (C) 2024 Frédéric Pierret +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program. If not, see . +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import qubes +import qubes.exc +import qubes.vm +from qubes.vm import BaseVM + + +class RemoteVM(BaseVM): + + relayvm = qubes.VMProperty( + "relayvm", + load_stage=4, + allow_none=True, + default=None, + doc="Local qube used as relay.", + ) + + transport_rpc = qubes.property( + "transport_rpc", + type=str, + default=None, + doc="Transport RPC used by the relay.", + ) + + remote_name = qubes.property( + "remote_name", + type=str, + default=None, + doc="Name on the remote host.", + ) + + def __init__(self, app, xml, **kwargs): + super().__init__(app, xml, **kwargs) + + if xml is None: + self.events_enabled = True + self.fire_event("domain-init") + + def get_mem(self): + return 0 + + def get_mem_static_max(self): + return 0 + + def get_cputime(self): + return 0 + + @staticmethod + def is_running(): + # fixme: handle power management option + return True + + @staticmethod + def is_halted(): + # fixme: handle power management option + return False + + @staticmethod + def get_power_state(): + # fixme: handle power management option + return "Running" + + def start(self, **kwargs): + raise qubes.exc.QubesVMNotHaltedError(self, "Cannot start a RemoteVM.") + + def suspend(self): + raise qubes.exc.QubesVMError(self, "Cannot suspend a RemoteVM.") + + def shutdown(self): + raise qubes.exc.QubesVMError(self, "Cannot shutdown a RemoteVM.") + + def kill(self): + raise qubes.exc.QubesVMError(self, "Cannot kill a RemoteVM.") diff --git a/rpm_spec/core-dom0.spec.in b/rpm_spec/core-dom0.spec.in index 6453f00be..1f464ec63 100644 --- a/rpm_spec/core-dom0.spec.in +++ b/rpm_spec/core-dom0.spec.in @@ -404,6 +404,7 @@ done %{python3_sitelib}/qubes/vm/appvm.py %{python3_sitelib}/qubes/vm/dispvm.py %{python3_sitelib}/qubes/vm/qubesvm.py +%{python3_sitelib}/qubes/vm/remotevm.py %{python3_sitelib}/qubes/vm/standalonevm.py %{python3_sitelib}/qubes/vm/templatevm.py @@ -447,6 +448,7 @@ done %{python3_sitelib}/qubes/ext/gui.py %{python3_sitelib}/qubes/ext/audio.py %{python3_sitelib}/qubes/ext/pci.py +%{python3_sitelib}/qubes/ext/relay.py %{python3_sitelib}/qubes/ext/r3compatibility.py %{python3_sitelib}/qubes/ext/services.py %{python3_sitelib}/qubes/ext/supported_features.py diff --git a/setup.py b/setup.py index bc2d4f697..fa2deb9d3 100644 --- a/setup.py +++ b/setup.py @@ -58,22 +58,24 @@ def run(self): 'StandaloneVM = qubes.vm.standalonevm:StandaloneVM', 'AdminVM = qubes.vm.adminvm:AdminVM', 'DispVM = qubes.vm.dispvm:DispVM', + 'RemoteVM = qubes.vm.remotevm:RemoteVM', ], 'qubes.ext': [ 'qubes.ext.admin = qubes.ext.admin:AdminExtension', + 'qubes.ext.audio = qubes.ext.audio:AUDIO', 'qubes.ext.backup_restore = ' 'qubes.ext.backup_restore:BackupRestoreExtension', + 'qubes.ext.block = qubes.ext.block:BlockDeviceExtension', 'qubes.ext.core_features = qubes.ext.core_features:CoreFeatures', 'qubes.ext.custom_persist = qubes.ext.custom_persist:CustomPersist', 'qubes.ext.gui = qubes.ext.gui:GUI', - 'qubes.ext.audio = qubes.ext.audio:AUDIO', - 'qubes.ext.r3compatibility = qubes.ext.r3compatibility:R3Compatibility', 'qubes.ext.pci = qubes.ext.pci:PCIDeviceExtension', - 'qubes.ext.block = qubes.ext.block:BlockDeviceExtension', + 'qubes.ext.r3compatibility = qubes.ext.r3compatibility:R3Compatibility', + 'qubes.ext.relay = qubes.ext.relay:Relay', 'qubes.ext.services = qubes.ext.services:ServicesExtension', 'qubes.ext.supported_features = qubes.ext.supported_features:SupportedFeaturesExtension', - 'qubes.ext.windows = qubes.ext.windows:WindowsFeatures', 'qubes.ext.vm_config = qubes.ext.vm_config:VMConfig', + 'qubes.ext.windows = qubes.ext.windows:WindowsFeatures', ], 'qubes.devices': [ 'pci = qubes.ext.pci:PCIDevice',