Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions doc/qubes-vm/remotevm.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
:py:mod:`qubes.vm.remotevm` -- Remote VM
==========================================

.. automodule:: qubes.vm.remotevm
:members:
:show-inheritance:

.. vim: ts=3 sw=3 et
14 changes: 11 additions & 3 deletions qubes/api/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
14 changes: 14 additions & 0 deletions qubes/api/internal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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),
}
Expand Down
17 changes: 10 additions & 7 deletions qubes/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -503,15 +503,15 @@ 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``
"""

# 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__
Expand Down Expand Up @@ -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):
Expand All @@ -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):
Expand Down Expand Up @@ -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(
Expand Down
5 changes: 3 additions & 2 deletions qubes/ext/block.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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:
Expand Down
65 changes: 65 additions & 0 deletions qubes/ext/relay.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
#
# The Qubes OS Project, https://www.qubes-os.org/
#
# Copyright (C) 2024 Frédéric Pierret <frederic.pierret@qubes-os.org>
#
# 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 <https://www.gnu.org/licenses/>.
#

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)

Check warning on line 32 in qubes/ext/relay.py

View check run for this annotation

Codecov / codecov/patch

qubes/ext/relay.py#L32

Added line #L32 was not covered by tests

@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(

Check warning on line 40 in qubes/ext/relay.py

View check run for this annotation

Codecov / codecov/patch

qubes/ext/relay.py#L36-L40

Added lines #L36 - L40 were not covered by tests
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)

Check warning on line 47 in qubes/ext/relay.py

View check run for this annotation

Codecov / codecov/patch

qubes/ext/relay.py#L46-L47

Added lines #L46 - L47 were not covered by tests

@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)

Check warning on line 56 in qubes/ext/relay.py

View check run for this annotation

Codecov / codecov/patch

qubes/ext/relay.py#L55-L56

Added lines #L55 - L56 were not covered by tests

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
)
6 changes: 6 additions & 0 deletions qubes/tests/api_internal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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": {
Expand All @@ -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),
},
}
Expand Down
84 changes: 84 additions & 0 deletions qubes/tests/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
#

import os
import unittest
import unittest.mock as mock

import lxml.etree
Expand All @@ -35,6 +36,8 @@
import logging
import time

from qubes.tests.vm.qubesvm import TestQubesDB


class TestApp(qubes.tests.TestEmitter):
pass
Expand Down Expand Up @@ -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"
Expand Down
24 changes: 24 additions & 0 deletions qubes/vm/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,21 @@
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

Check warning on line 278 in qubes/vm/__init__.py

View check run for this annotation

Codecov / codecov/patch

qubes/vm/__init__.py#L278

Added line #L278 was not covered by tests
def __hash__(self):
return self.qid

def __lt__(self, other):
if not isinstance(other, qubes.vm.BaseVM):
return NotImplemented
return self.name < other.name

Check warning on line 285 in qubes/vm/__init__.py

View check run for this annotation

Codecov / codecov/patch

qubes/vm/__init__.py#L285

Added line #L285 was not covered by tests

@qubes.stateless_property
def klass(self):
"""Domain class name"""
Expand Down Expand Up @@ -342,6 +357,13 @@
" ".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
Expand Down Expand Up @@ -478,6 +500,8 @@
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
Expand Down
Loading