diff --git a/debian/qubes-utils.install b/debian/qubes-utils.install index 29846073..c70c3a9d 100644 --- a/debian/qubes-utils.install +++ b/debian/qubes-utils.install @@ -1,6 +1,8 @@ usr/bin/meminfo-writer lib/systemd/system/qubes-meminfo-writer.service +lib/systemd/system/qubes-block-mount-watcher.service usr/lib/qubes/* usr/lib/udev/* usr/lib/tmpfiles.d/xen-devices-qubes.conf etc/xen/scripts/qubes-block +etc/qubes-rpc/qubes.RefreshBlockDevices diff --git a/rpm_spec/qubes-utils.spec.in b/rpm_spec/qubes-utils.spec.in index a6eb2134..064b8e55 100644 --- a/rpm_spec/qubes-utils.spec.in +++ b/rpm_spec/qubes-utils.spec.in @@ -115,14 +115,29 @@ make install install-selinux DESTDIR=%{buildroot} PYTHON=%{__python3} NO_REBUILD %systemd_post qubes-meminfo-writer-dom0.service # VM %systemd_post qubes-meminfo-writer.service +%systemd_post qubes-block-mount-watcher.service + +# Apply the preset on upgrade +if [ $1 -gt 1 ] && \ + [ ! -e %{_localstatedir}/lib/rpm-state/qubes-utils-mount-watcher ]; then + mkdir -p %{_localstatedir}/lib/rpm-state + touch %{_localstatedir}/lib/rpm-state/qubes-utils-mount-watcher + systemctl preset qubes-block-mount-watcher.service >/dev/null 2>&1 || : +fi +if systemctl is-enabled --quiet qubes-block-mount-watcher.service \ + >/dev/null 2>&1; then + systemctl start qubes-block-mount-watcher.service >/dev/null 2>&1 || : +fi %preun %systemd_preun qubes-meminfo-writer-dom0.service %systemd_preun qubes-meminfo-writer.service +%systemd_preun qubes-block-mount-watcher.service %postun %systemd_postun_with_restart qubes-meminfo-writer-dom0.service %systemd_postun_with_restart qubes-meminfo-writer.service +%systemd_postun_with_restart qubes-block-mount-watcher.service %post libs -p /sbin/ldconfig %postun libs -p /sbin/ldconfig @@ -136,11 +151,15 @@ rm -rf $RPM_BUILD_ROOT %_tmpfilesdir/xen-devices-qubes.conf %dir %{_prefix}/lib/qubes %{_prefix}/lib/qubes/udev-* +%{_prefix}/lib/qubes/qubes-block-mount-watcher %{_bindir}/meminfo-writer %{_unitdir}/qubes-meminfo-writer.service %{_unitdir}/qubes-meminfo-writer-dom0.service +%{_unitdir}/qubes-block-mount-watcher.service +%{_prefix}/lib/systemd/system-preset/75-qubes-block-mount-watcher.preset %dir %_includedir/qubes /etc/xen/scripts/qubes-block +/etc/qubes-rpc/qubes.RefreshBlockDevices %files -n python%{python3_pkgversion}-qubesimgconverter %dir %{python3_sitelib}/qubesimgconverter diff --git a/udev/Makefile b/udev/Makefile index ac64a84e..f73eb03b 100644 --- a/udev/Makefile +++ b/udev/Makefile @@ -4,6 +4,7 @@ install: mkdir -p $(DESTDIR)$(SYSLIBDIR)/udev/rules.d cp udev-qubes-block.rules $(DESTDIR)$(SYSLIBDIR)/udev/rules.d/99-qubes-block.rules cp udev-qubes-usb.rules $(DESTDIR)$(SYSLIBDIR)/udev/rules.d/99-qubes-usb.rules + cp udev-qubes-vbd.rules $(DESTDIR)$(SYSLIBDIR)/udev/rules.d/99-qubes-vbd.rules cp udev-qubes-misc.rules $(DESTDIR)$(SYSLIBDIR)/udev/rules.d/99-qubes-misc.rules cp udev-qubes-dmroot.rules $(DESTDIR)$(SYSLIBDIR)/udev/rules.d/90-qubes-dmroot.rules mkdir -p $(DESTDIR)$(SYSLIBDIR)/tmpfiles.d @@ -14,3 +15,21 @@ install: cp udev-block-remove $(DESTDIR)$(SCRIPTSDIR) cp udev-usb-add-change $(DESTDIR)$(SCRIPTSDIR) cp udev-usb-remove $(DESTDIR)$(SCRIPTSDIR) + cp qubes-block-mount-watcher $(DESTDIR)$(SCRIPTSDIR) + +ifeq (1,${DEBIANBUILD}) + mkdir -p $(DESTDIR)/lib/systemd/system + install -m 0644 qubes-block-mount-watcher.service \ + $(DESTDIR)/lib/systemd/system/ +else + mkdir -p $(DESTDIR)/usr/lib/systemd/system + install -m 0644 qubes-block-mount-watcher.service \ + $(DESTDIR)/usr/lib/systemd/system/ + + mkdir -p $(DESTDIR)/usr/lib/systemd/system-preset + install -m 0644 qubes-block-mount-watcher.preset \ + $(DESTDIR)/usr/lib/systemd/system-preset/75-qubes-block-mount-watcher.preset +endif + + mkdir -p $(DESTDIR)/etc/qubes-rpc + cp qubes.RefreshBlockDevices $(DESTDIR)/etc/qubes-rpc diff --git a/udev/qubes-block-mount-watcher b/udev/qubes-block-mount-watcher new file mode 100755 index 00000000..d3320972 --- /dev/null +++ b/udev/qubes-block-mount-watcher @@ -0,0 +1,75 @@ +#!/usr/bin/python3 +# +# The Qubes OS Project, https://www.qubes-os.org +# +# Copyright (C) 2026 Piotr Bartman-Szwarc +# +# 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, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, +# USA. + +""" +Re-evaluate exported block devices when something is (un)mounted. + +Mounting a filesystem does not generate a block uevent. +Note: Swap is not covered. +""" + +import os +import select +import subprocess + +MOUNTINFO = "/proc/self/mountinfo" + + +def get_mounted(mountinfo) -> set[str]: + """Returns numbers ("major:minor") of currently mounted devices.""" + mountinfo.seek(0) + result = set() + for line in mountinfo.read().splitlines(): + fields = line.split() + # 3rd field is the number + if len(fields) > 2: + result.add(fields[2]) + return result + + +def refresh(device: str): + """Re-run the udev rules for a device.""" + syspath = "/sys/dev/block/" + device + if not os.path.isdir(syspath): + # virtual + return + subprocess.call(["udevadm", "trigger", "--action=change", syspath]) + + +def main(): + with open(MOUNTINFO, "r", encoding="ascii", errors="replace") as mountinfo: + poller = select.poll() + poller.register(mountinfo, select.POLLPRI | select.POLLERR) + + previous = get_mounted(mountinfo) + for dev in previous: + refresh(dev) + + while True: + poller.poll() + current = get_mounted(mountinfo) + for dev in current.symmetric_difference(previous): + refresh(dev) + previous = current + + +if __name__ == "__main__": + main() diff --git a/udev/qubes-block-mount-watcher.preset b/udev/qubes-block-mount-watcher.preset new file mode 100644 index 00000000..eefbed13 --- /dev/null +++ b/udev/qubes-block-mount-watcher.preset @@ -0,0 +1 @@ +enable qubes-block-mount-watcher.service diff --git a/udev/qubes-block-mount-watcher.service b/udev/qubes-block-mount-watcher.service new file mode 100644 index 00000000..4002b992 --- /dev/null +++ b/udev/qubes-block-mount-watcher.service @@ -0,0 +1,16 @@ +[Unit] +Description=Block device mount watcher +After=qubes-db.service qubes-db-dom0.service systemd-udevd.service +Wants=qubes-db.service + +[Service] +Type=simple +# Shows to dom0 that this backend supports the `used` markers. +ExecStartPre=/usr/bin/qubesdb-write /qubes-device-usage-tracking True +ExecStopPost=-/usr/bin/qubesdb-rm /qubes-device-usage-tracking +ExecStart=/usr/lib/qubes/qubes-block-mount-watcher +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/udev/qubes.RefreshBlockDevices b/udev/qubes.RefreshBlockDevices new file mode 100755 index 00000000..36e98539 --- /dev/null +++ b/udev/qubes.RefreshBlockDevices @@ -0,0 +1,33 @@ +#!/bin/sh + +# Refresh exported devices, by re-triggering udev change events. +# Called after exporting a device: the partitions of an exported block device +# are removed from QubesDB. +# +# stdin: device node path relative to /dev (e.g. "sda" or "mapper/dmroot"). + +read -r untrusted_name + +case "$untrusted_name" in + ""|-*|*[!a-zA-Z0-9/_-]*) exit 1 ;; +esac +name="$untrusted_name" + +# Wait (3s max) for vbd to appear; attach is async and no uevent is sent +# to fix an early recompute. On timeout recompute anyway: attach likely +# failed; if not, partitions stay listed until the next block event. +dev_hex=$(stat -c %t:%T "/dev/$name" 2>/dev/null) +i=0 +while [ "$i" -lt 30 ] && [ -n "$dev_hex" ] && [ "$dev_hex" != 0:0 ]; do + grep -qxF "$dev_hex" \ + /sys/bus/xen-backend/drivers/vbd/vbd-*/physical_device 2>/dev/null \ + && break + sleep 0.1 + i=$((i + 1)) +done + +for dev in "/dev/$name" "/dev/$name"?*; do + [ -b "$dev" ] && udevadm trigger --action=change "$dev" +done + +exec udevadm settle --timeout=5 diff --git a/udev/udev-block-add-change b/udev/udev-block-add-change index 7add05c0..bde5f33d 100755 --- a/udev/udev-block-add-change +++ b/udev/udev-block-add-change @@ -1,6 +1,6 @@ #!/bin/bash -shopt -s nullglob +shopt -s nullglob globstar export LC_CTYPE=en_US.UTF-8 @@ -10,6 +10,27 @@ SIZE=$[ $(cat /sys/$DEVPATH/size) * 512 ] MODE=w QDB_KEY="/qubes-block-devices/$NAME" +# USB ancestor port name (e.g. "1-1") if this block device is exposed via USB; +# empty otherwise. Computed early so xs_set_usb_used can use it. +USB_PARENT="" +USB_PARENT_SYSPATH="" +if echo "$DEVPATH" | grep -q '/host'; then + # DEVPATH for a USB-connected block device ends with: + # ...///host.../... + # Walk up /sys to find the USB device directory name matching bus-port + # format (e.g. "1-1", not "1-1:1.0"). + walk="/sys$DEVPATH" + while [ -n "$walk" ] && [ "$walk" != "/" ] && [ "$walk" != "/sys" ]; do + walk=${walk%/*} + candidate=${walk##*/} + if [[ "$candidate" =~ ^[0-9]+-[0-9]+(\.[0-9]+)*$ ]]; then + USB_PARENT_SYSPATH=$walk + USB_PARENT=$candidate + break + fi + done +fi + xs_remove() { if is_attached /sys$DEVPATH; then return 0 @@ -21,6 +42,21 @@ xs_remove() { fi } +# Mark only the USB ancestor (if any) as used. Needed on its own when the +# device itself is removed from QDB (used locally, so not manageable from +# dom0) but the whole USB device still must not be attachable elsewhere. +xs_set_usb_used() { + if [ -n "$USB_PARENT" ]; then + local usb_safe="${USB_PARENT//./_}" + # check if USB device is exposed + if qubesdb-read -q "/qubes-usb-devices/${usb_safe}/desc" >/dev/null + then + qubesdb-write "/qubes-usb-devices/${usb_safe}/used" "$1" + qubesdb-write /qubes-usb-devices '' + fi + fi +} + is_used() { local sys_devpath=$1 local devname=$(grep ^DEVNAME= $sys_devpath/uevent | cut -f 2 -d =) @@ -49,6 +85,26 @@ refresh_another() { env -i PATH=$PATH $launch_env $0 } +# If any block device exposed by the USB ancestor (including sibling block +# devices and all their partitions) is currently used locally. +# Used to decide if the USB ancestor can be set free. +usb_parent_still_in_use() { + [ -n "$USB_PARENT_SYSPATH" ] || return 1 + local blockdev part + # The interface-to-host depth varies by driver + for blockdev in "$USB_PARENT_SYSPATH"/**/block/*; do + if is_used "$blockdev"; then + return 0 + fi + for part in "$blockdev/${blockdev##*/}"*; do + if [ -d "$part" ] && is_used "$part"; then + return 0 + fi + done + done + return 1 +} + is_attached() { dev_hex=$(stat -c %t:%T /dev/$(basename $1)) if [ -z "$dev_hex" -o "$dev_hex" = "0:0" ]; then @@ -95,21 +151,23 @@ if [ -z "$QUBES_EXPORT_BLOCK_DEVICE" ] && [ "$DM_UDEV_DISABLE_DISK_RULES_FLAG" = exit 0 fi +# Note: mounting or unmounting a filesystem does not generate a block uevent, +# so this script is not run by itself when local use starts or ends. +# See `qubes-block-mount-watcher`. + # device itself is already used if is_used /sys$DEVPATH; then xs_remove + xs_set_usb_used True exit 0 fi # or one of its partitions is used -# or already attached (prevent attaching both device and its partition(s) at -# the same time) +USED="" for part in /sys$DEVPATH/$NAME*; do - if [ -d $part ]; then - if is_used $part || is_attached $part; then - xs_remove - exit 0 - fi + if [ -d $part ] && is_used $part; then + USED=1 + break fi done @@ -166,8 +224,14 @@ fi if [ -f /sys$DEVPATH/partition ]; then parent=$(basename "$(dirname "$DEVPATH")") -elif echo "$DEVPATH" | grep -q '/host'; then - parent=$(basename "$(sed 's|/host.*$||' <<< "$DEVPATH")") +elif [ -n "$USB_PARENT" ]; then + parent="$USB_PARENT" +fi + +if [ -n "$USED" ]; then + USED_VALUE=True +else + USED_VALUE=False fi # The last one is meant to trigger watches @@ -176,7 +240,22 @@ qubesdb-write \ "$QDB_KEY/size" "$SIZE" \ "$QDB_KEY/mode" "$MODE" \ "$QDB_KEY/parent" "$parent" \ + "$QDB_KEY/used" "$USED_VALUE" \ /qubes-block-devices '' +# If this device or one of its children is in use: keep the parent used. +if [ -n "$USED" ]; then + xs_set_usb_used True + exit 0 +fi + +# This device is free, but a sibling device may still be in use +# in that case keep the USB ancestor used. +if usb_parent_still_in_use; then + xs_set_usb_used True +else + xs_set_usb_used False +fi + # Make sure that block backend is loaded /sbin/modprobe xen-blkback 2> /dev/null || /sbin/modprobe blkbk diff --git a/udev/udev-qubes-vbd.rules b/udev/udev-qubes-vbd.rules new file mode 100644 index 00000000..86ebbd0f --- /dev/null +++ b/udev/udev-qubes-vbd.rules @@ -0,0 +1,10 @@ +# Re-evaluate exported block devices when a xen vbd disappears. +# +# Not on the udev watch event: it fires when blkback closes the device, +# still before the vbd is unregistered, so a refresh there would see it as +# attached! +# By now physical_device is gone too, so we cannot tell which device it was, +# hence we check all; the block rules drop the irrelevant ones early. +# This works for `detach`; `attach` is handled by `qubes.RefreshBlockDevices`. +SUBSYSTEM=="xen-backend", KERNEL=="vbd-*", ACTION=="remove", \ + RUN+="/usr/bin/udevadm trigger --action=change --subsystem-match=block" \ No newline at end of file diff --git a/udev/udev-usb-add-change b/udev/udev-usb-add-change index 36144441..6f3e85d7 100755 --- a/udev/udev-usb-add-change +++ b/udev/udev-usb-add-change @@ -28,11 +28,15 @@ fi QDB_KEY="/qubes-usb-devices/$XSNAME" +USED=`qubesdb-read -q "$QDB_KEY/used" 2>/dev/null` +[ -n "$USED" ] || USED=False + # The last one is meant to trigger watches qubesdb-write \ "$QDB_KEY/desc" "$DESC" \ "$QDB_KEY/usb-ver" "$VERSION" \ "$QDB_KEY/interfaces" "$ID_USB_INTERFACES" \ + "$QDB_KEY/used" "$USED" \ /qubes-usb-devices '' # Make sure PVUSB backend driver is loaded.