Skip to content
Open
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
52 changes: 52 additions & 0 deletions qubes/device_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,32 @@ def qbool(value):
return bool(value)


def qbool_untrusted_busy(untrusted_busy, log=None) -> bool:
"""
Parse the untrusted ``busy`` marker read from a backend VM's QubesDB.

A missing value (:py:obj:`None`) or a boolean-false literal
(``False``/``0``/``no``/``off``) means the device is free. A
boolean-true literal (``True``/``1``/``yes``/``on``) means it is busy.

Any other, unparsable value is treated as **busy**: it's safer to avoid
attachment of such device. Such a case is logged as a warning.
"""
if untrusted_busy is None:
return False
if isinstance(untrusted_busy, bytes):
untrusted_busy = untrusted_busy.decode("ascii", errors="replace")
try:
return qbool(untrusted_busy.strip())
except QubesValueError:
if log is not None:
log.warning(
"Invalid 'busy' marker %r, assuming device is busy",
untrusted_busy,
)
return True


class DeviceSerializer:
"""
Group of method for serialization of device properties.
Expand Down Expand Up @@ -916,6 +942,7 @@ def __init__(
parent: Optional["DeviceInfo"] = None,
attachment: Optional[QubesVM] = None,
device_id: Optional[str] = None,
busy: Optional[bool] = None,
**kwargs,
):
super().__init__(port, device_id)
Expand All @@ -928,6 +955,7 @@ def __init__(
self._interfaces = interfaces
self._parent = parent
self._attachment = attachment
self._busy = busy

self.data = kwargs

Expand Down Expand Up @@ -1082,6 +1110,17 @@ def attachment(self) -> Optional[QubesVM]:
"""
return self._attachment

@property
def busy(self) -> bool:
"""
Is the device currently busy (unavailable for attachment)?

True when the device or one of its children is in use: attached
to a VM or used locally in the backend VM. The device remains
visible but cannot be attached until it is freed.
"""
return bool(self._busy)

def serialize(self) -> bytes:
"""
Serialize an object to be transmitted via Qubes API.
Expand All @@ -1105,6 +1144,11 @@ def serialize(self) -> bytes:
"attachment", self.attachment.name
)

if self.busy:
properties += b" " + DeviceSerializer.pack_property(
"busy", "True"
)

properties += b" " + DeviceSerializer.pack_property(
"interfaces", "".join(repr(ifc) for ifc in self.interfaces)
)
Expand Down Expand Up @@ -1188,6 +1232,14 @@ def _deserialize(
del properties["parent_port_id"]
del properties["parent_devclass"]

# A missing property means free; otherwise parse strictly. An
# invalid value raises QubesValueError, which the caller
# (deserialize) turns into an UnknownDevice.
untrusted_busy = properties.pop("busy", None)
properties["busy"] = (
qbool(untrusted_busy) if untrusted_busy is not None else False
)

return cls(**properties)

@property
Expand Down
189 changes: 187 additions & 2 deletions qubes/ext/block.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@
SYSTEM_DISKS_DOM0_KERNEL = SYSTEM_DISKS + ("xvdd",)


def get_busy_qdb_key(port_id):
return f"/qubes-block-devices/{port_id}/busy"


class BlockDevice(qubes.device_protocol.DeviceInfo):

def __init__(self, port: qubes.device_protocol.Port):
Expand Down Expand Up @@ -195,7 +199,10 @@ def parent_device(self) -> Optional[qubes.device_protocol.DeviceInfo]:
parent_ident, sep, interface_num = self._sanitize(
untrusted_parent_info
).partition(":")
devclass = "usb" if sep == ":" else "block"
if sep == ":" or re.match(r"^\d+-", parent_ident):
devclass = "usb"
else:
devclass = "block"
if not parent_ident:
return None
try:
Expand Down Expand Up @@ -242,6 +249,25 @@ def _is_attached_to(self, vm):
return True
return False

@property
def busy(self) -> bool:
"""
Is this device busy? A device is considered busy if it or any of
its children is in use: attached to a VM or used locally in the
backend VM (mounted, part of a device-mapper, enabled swap).
"""
if self._busy is None:
if not self.backend_domain or not self.backend_domain.is_running():
# don't cache this value
return False
untrusted_busy = self.backend_domain.untrusted_qdb.read(
get_busy_qdb_key(self.port_id)
)
self._busy = qubes.device_protocol.qbool_untrusted_busy(
untrusted_busy, self.backend_domain.log
)
return self._busy

@property # type: ignore[misc]
def device_id(self) -> str:
"""
Expand Down Expand Up @@ -285,6 +311,18 @@ def _sanitize(
c if c in set(safe_chars) else "_" for c in untrusted_device_desc
)

def mark_busy(self, busy: bool) -> None:
"""Set or clear the busy marker in the backend VM's QDB."""
key = get_busy_qdb_key(self.port_id)
if not busy:
try:
self.backend_domain.untrusted_qdb.rm(key)
except Exception: # pylint: disable=broad-except
pass
else:
self.backend_domain.untrusted_qdb.write(key, b"True")
self._busy = busy


def _try_get_block_device_info(app, disk):
if disk.get("type") != "block":
Expand Down Expand Up @@ -330,6 +368,19 @@ def on_domain_init_load(self, vm, event):
for dev in self.on_device_list_block(vm, None)
)
self.devices_cache[vm.name] = current_devices
# Re-establish busy markers for partitions already attached
# before this qubesd instance started (e.g. after qubesd restart).
for port_id, front_vm in device_attachments.items():
if front_vm is not None:
self._mark_parents_busy(
BlockDevice(
Port(
backend_domain=vm,
port_id=port_id,
devclass="block",
)
)
)
else:
self.devices_cache[vm.name] = {}

Expand Down Expand Up @@ -383,7 +434,7 @@ def device_get(vm, port_id):
if not untrusted_qubes_device_attrs:
return None
return BlockDevice(
Port(backend_domain=vm, port_id=port_id, devclass="block")
Port(backend_domain=vm, port_id=port_id, devclass="block"),
)

@qubes.ext.handler("device-list:block")
Expand Down Expand Up @@ -520,6 +571,12 @@ def pre_attachment_internal(
)
raise qubes.exc.UnrecognizedDevice()

# DeviceAlreadyAttached is checked below
if device.busy and not device.attachment:
raise qubes.exc.QubesValueError(
f"Device {device} is busy: it or one of its children is in use."
)

# validate options
for option, value in options.items():
if option == "frontend-dev":
Expand Down Expand Up @@ -585,6 +642,122 @@ def pre_attachment_internal(
vm, options.get("devtype", "disk")
)

self._mark_parents_busy(device)

def _mark_parents_busy(self, device):
"""Set busy for the parent chain in the backend VM's QDB."""
try:
if not device.parent_device:
return
if not device.backend_domain.is_running():
# something change in meantime, ignore it
return
self._mark_parent_chain(device, busy=True)
except Exception: # pylint: disable=broad-except
# best effort
pass

async def _refresh_busy_after_detach(self, device):
"""
Ask the backend VM to refresh `busy` for the detached device's subtree
by re-triggering udev change events.

The udev script rebuilds the state from what only the backend can
see (vbd attachments in sysfs and the local mount table), so this
never clears a marker owed to a local mount.
"""
backend = device.backend_domain
if not backend.is_running():
return
# vbd teardown is asynchronous: refreshing while the vbd is still
# visible in the backend's sysfs would re-mark the device as busy
# with no later event to clear it.
# TODO: better idea?
await asyncio.sleep(1)

name = device.port_id.replace("_", "/")
try:
await backend.run_service_for_stdio(
"qubes.RefreshBlockDevices",
user="root",
input=name.encode() + b"\n",
)
except Exception: # pylint: disable=broad-except
backend.log.warning(
"block refresh failed for %s, "
"freeing parents while ignoring local use.",
device.port_id,
)
self._unmark_parents_busy(device)

def _unmark_parents_busy(self, device):
"""
Clear busy for the parent chain in the backend VM's QDB.

Ancestors shared with other still-attached devices (sibling
partitions of the same disk, or sibling block devices of the same
USB device) are kept busy.

Fallback: prefer `_refresh_busy_after_detach`, which take into account
local use.
"""
backend = device.backend_domain
if not backend.is_running():
# something change in meantime, ignore it
return

try:
keep_busy = set()
for dev_id, front_vm in self.devices_cache[backend.name].items():
if front_vm is None or dev_id == device.port_id:
continue
other = BlockDevice(
Port(backend_domain=backend, port_id=dev_id,
devclass="block")
)
for ancestor in self._parent_chain(other):
keep_busy.add((ancestor.devclass, ancestor.port_id))
self._mark_parent_chain(device, busy=False, skip=keep_busy)
except Exception: # pylint: disable=broad-except
# best effort
pass

@staticmethod
def _parent_chain(device):
"""Yield ancestors of the device."""
current = device
# limit the depth: better to leave some ancestors unmarked than to
# loop forever on a corrupted or malicious parent chain
for _ in range(8):
parent = current.parent_device
if parent is None or isinstance(
parent, qubes.device_protocol.UnknownDevice
):
break
yield parent
if parent.devclass == "usb":
break # USB is the top of the hierarchy
current = parent

def _mark_parent_chain(self, device, busy, skip=frozenset()):
"""
Walk the parent_device chain and set busy for each ancestor.

Ancestors listed in `skip` (as (devclass, port_id) pairs) are left
untouched. Trigger writes are deferred until all busy keys have
been written so that watch callbacks see a consistent QDB state.
"""
pending_triggers = set()

for parent in self._parent_chain(device):
if (parent.devclass, parent.port_id) in skip:
continue
parent.mark_busy(busy)
pending_triggers.add(f"/qubes-{parent.devclass}-devices")

for trigger in pending_triggers:
device.backend_domain.untrusted_qdb.write(trigger, b"")

@qubes.ext.handler("domain-start")
async def on_domain_start(self, vm, _event, **_kwargs):
# pylint: disable=unused-argument
Expand All @@ -598,6 +771,8 @@ async def on_domain_start(self, vm, _event, **_kwargs):
for device in assignment.devices:
if isinstance(device, qubes.device_protocol.UnknownDevice):
continue
if device.busy:
continue
if device.attachment:
continue
if not assignment.matches(device):
Expand Down Expand Up @@ -662,6 +837,12 @@ async def on_domain_shutdown(self, vm, event, **_kwargs):
"device-detach:block", port=dev.port
)
)
# the frontend is gone: clear the busy markers
detached = BlockDevice(Port(domain, dev_id, "block"))
detached.mark_busy(False)
asyncio.ensure_future(
self._refresh_busy_after_detach(detached)
)
else:
new_cache[domain.name][dev_id] = front_vm
self.devices_cache = new_cache.copy()
Expand Down Expand Up @@ -703,4 +884,8 @@ def on_device_pre_detached_block(self, vm, event, port):
device=attached_device, vm=vm, options=options
)
)
attached_device.mark_busy(False)
asyncio.ensure_future(
self._refresh_busy_after_detach(attached_device)
)
break
3 changes: 3 additions & 0 deletions qubes/ext/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ def device_list_change(
assignment.matches(device)
and device.port_id in added
and device.port_id not in attached
# do not auto-attach busy devices: attach would be
# refused anyway, leaving an unhandled task exception
and not device.busy
):
frontends = to_attach.get(device.port_id, {})
# make it unique
Expand Down
Loading