From 86d5eefa7561d696ac9787904214219639021293 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Marczykowski-G=C3=B3recki?= Date: Wed, 12 Nov 2025 05:11:12 +0100 Subject: [PATCH 1/4] Dynamically allocate video device Register /dev/video device dynamically (instead of hardcoding /dev/video0), and unregister it when no longer in use (instead of unloading the whole module). This allows using webcam and screen share simultaneously. This requires a simple ioctl to /dev/v4l2loopback device, do it from python to avoid pulling in extra dependencies (v4l2-utils). The structure is a bit messy: qubes-video-companion bash script calls setup.py (now written in python), and then qrexec-client-vm (in C) that calls back receiver.py (which then execs gst-launch). And finally qubes-video-companion calls destroy.py to clean things up. This could be improved by rewriting qubes-video-companion in python, and including setup.py/destroy.py as functions, but on the other hand, bash script (running during the whole connection). Fixes QubesOS/qubes-issues#9625 --- Makefile | 2 +- receiver/common.sh | 15 ---- receiver/destroy.py | 51 ++++++++++++ receiver/destroy.sh | 25 ------ receiver/qubes-video-companion | 17 +--- receiver/receiver.py | 11 ++- receiver/setup.py | 109 +++++++++++++++++++++++++ receiver/setup.sh | 25 ------ rpm_spec/qubes-video-companion.spec.in | 5 +- 9 files changed, 175 insertions(+), 85 deletions(-) delete mode 100755 receiver/common.sh create mode 100644 receiver/destroy.py delete mode 100755 receiver/destroy.sh create mode 100644 receiver/setup.py delete mode 100755 receiver/setup.sh diff --git a/Makefile b/Makefile index 6b4c899..1be2a1b 100644 --- a/Makefile +++ b/Makefile @@ -36,7 +36,7 @@ install-vm: install-both $(INSTALL_DIR) $(DESTDIR)$(BINDIR) $(INSTALL_PROGRAM) receiver/$(PKGNAME) $(DESTDIR)$(BINDIR) $(INSTALL_DIR) $(DESTDIR)$(DATADIR)/$(PKGNAME)/receiver - $(INSTALL_PROGRAM) receiver/setup.sh receiver/receiver.py receiver/destroy.sh receiver/common.sh $(DESTDIR)$(DATADIR)/$(PKGNAME)/receiver + $(INSTALL_PROGRAM) receiver/setup.py receiver/receiver.py receiver/destroy.py $(DESTDIR)$(DATADIR)/$(PKGNAME)/receiver $(INSTALL_DIR) $(DESTDIR)$(SYSCONFDIR)/qubes/rpc-config echo 'wait-for-session=1' > $(DESTDIR)$(SYSCONFDIR)/qubes/rpc-config/qvc.Webcam echo 'wait-for-session=1' > $(DESTDIR)$(SYSCONFDIR)/qubes/rpc-config/qvc.ScreenShare diff --git a/receiver/common.sh b/receiver/common.sh deleted file mode 100755 index 8788612..0000000 --- a/receiver/common.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/bin/bash - -# Copyright (C) 2021 Elliot Killick -# Licensed under the MIT License. See LICENSE file for details. - -[ "$DEBUG" == 1 ] && set -x - -set -E # Enable function inheritance of traps -trap exit ERR - -# Test if v4l2loopback kernel module is installed -test_v4l2loopback() { - # call modinfo via sudo because Whonix sets 0700 on /lib/modules - sudo --non-interactive modinfo v4l2loopback &> /dev/null -} diff --git a/receiver/destroy.py b/receiver/destroy.py new file mode 100644 index 0000000..44f2ae1 --- /dev/null +++ b/receiver/destroy.py @@ -0,0 +1,51 @@ +#!/usr/bin/python3 -- +# +# Copyright (C) 2025 Marek Marczykowski-Górecki +# +# Licensed under the MIT License. See LICENSE file for details. + +import errno +import sys +import os +import fcntl +import time +import subprocess + + +# V4L2LOOPBACK_CTL_REMOVE = 0x40487e02 +# use legacy numbers since the change was recent +V4L2LOOPBACK_CTL_REMOVE = 0x4C81 + + +def unregister_device(dev_nr): + ctrl_fd = os.open("/dev/v4l2loopback", os.O_RDWR) + try: + message = ("Please close any window that has an open video stream " + "so kernel modules can be securely unloaded...") + + while True: + try: + fcntl.ioctl(ctrl_fd, V4L2LOOPBACK_CTL_REMOVE, dev_nr) + break + except OSError as e: + if e.errno == errno.EBUSY: + print(message, file=sys.stderr) + subprocess.call( + ["notify-send", "Qubes Video Companion", message] + ) + time.sleep(10) + else: + raise + finally: + os.close(ctrl_fd) + + +def main(argv): + if len(argv) != 2 or not argv[1].startswith("/dev/video"): + raise RuntimeError("Wrong arguments, expected /dev/video* path") + dev_nr = int(argv[1][len("/dev/video") :]) + unregister_device(dev_nr) + + +if __name__ == "__main__": + main(sys.argv) diff --git a/receiver/destroy.sh b/receiver/destroy.sh deleted file mode 100755 index 4267178..0000000 --- a/receiver/destroy.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/bin/bash - -# Copyright (C) 2021 Elliot Killick -# Licensed under the MIT License. See LICENSE file for details. - -[ "$DEBUG" == 1 ] && set -x - -set -E # Enable function inheritance of traps -trap exit ERR - -# shellcheck source=receiver/common.sh -source /usr/share/qubes-video-companion/receiver/common.sh - -if ! test_v4l2loopback; then - exit 1 -fi - -# "videodev" is the Video4Linux (V4L) driver (V4L2 is the second version of V4L) -until sudo modprobe -r v4l2loopback videodev; do - message="Please close any window that has an open video stream so kernel modules can be securely unloaded..." - echo "$message" >&2 - notify-send "Qubes Video Companion" "$message" - - sleep 10 -done diff --git a/receiver/qubes-video-companion b/receiver/qubes-video-companion index 6dcef46..37ced9a 100755 --- a/receiver/qubes-video-companion +++ b/receiver/qubes-video-companion @@ -56,8 +56,7 @@ esac exit_clean () { exit_code="$?" - /usr/share/qubes-video-companion/receiver/destroy.sh - sudo rm -f "$qvc_lock_file" + /usr/share/qubes-video-companion/receiver/destroy.py "$dev_path" if [ "$video_source" = "webcam" ] && [ "$exit_code" = "141" ]; then echo "The webcam device is in use! Please stop any instance of Qubes Video Companion running on another qube." >&2 @@ -67,15 +66,7 @@ exit_clean () { exit "$exit_code" } -qvc_lock_file="/run/lock/qubes-video-companion" -if ! [ -f "$qvc_lock_file" ]; then - trap exit_clean EXIT - sudo touch "$qvc_lock_file" -else - echo "Qubes Video Companion is already running! Please stop the previous session before starting a new one. If you think this is an error, remove the lockfile $qvc_lock_file" >&2 - exit 1 -fi - -/usr/share/qubes-video-companion/receiver/setup.sh +dev_path=$(/usr/share/qubes-video-companion/receiver/setup.py "$video_source") +trap exit_clean EXIT # Filter standard error escape characters for safe printing to the terminal from the video sender -qrexec-client-vm --filter-escape-chars-stderr -- "$qube" "$qvc_service+$resolution" /usr/share/qubes-video-companion/receiver/receiver.py +qrexec-client-vm --filter-escape-chars-stderr -- "$qube" "$qvc_service+$resolution" /usr/share/qubes-video-companion/receiver/receiver.py "$dev_path" diff --git a/receiver/receiver.py b/receiver/receiver.py index c77542c..ebc95f0 100644 --- a/receiver/receiver.py +++ b/receiver/receiver.py @@ -11,8 +11,13 @@ def main(argv) -> NoReturn: - if len(argv) != 1: - raise RuntimeError("should not have any arguments") + dev_path = "/dev/video0" + if len(argv) == 2: + dev_path = argv[1] + elif len(argv) != 1: + raise RuntimeError( + "wrong arguments - expected only optional device path" + ) width, height, fps = read_video_parameters() @@ -45,7 +50,7 @@ def main(argv) -> NoReturn: "use-sink-caps=true", "!", "v4l2sink", - "device=/dev/video0", + "device=" + dev_path, "sync=false", ), ) diff --git a/receiver/setup.py b/receiver/setup.py new file mode 100644 index 0000000..20bb169 --- /dev/null +++ b/receiver/setup.py @@ -0,0 +1,109 @@ +#!/usr/bin/python3 -- +# +# Copyright (C) 2025 Marek Marczykowski-Górecki +# +# Licensed under the MIT License. See LICENSE file for details. + + +import sys +import struct +import os +import fcntl +import subprocess + +# see v4l2loopback.h: +# __s32 output_nr; +# __s32 unused; /*capture_nr;*/ +# +# /** +# * a nice name for your device +# * if (*card_label)==0, an automatic name is assigned +# */ +# char card_label[32]; +# +# /** +# * allowed frame size +# * if too low, default values are used +# */ +# __u32 min_width; +# __u32 max_width; +# __u32 min_height; +# __u32 max_height; +# +# /** +# * number of buffers to allocate for the queue +# * if set to <=0, default values are used +# */ +# __s32 max_buffers; +# +# /** +# * how many consumers are allowed to open this device concurrently +# * if set to <=0, default values are used +# */ +# __s32 max_openers; +# +# /** +# * set the debugging level for this device +# */ +# __s32 debug; +# +# /** +# * whether to announce OUTPUT/CAPTURE capabilities exclusively +# * for this device or not +# * (!exclusive_caps) +# * NOTE: this is going to be removed once separate output/capture +# * devices are implemented +# */ +# __s32 announce_all_caps; +v4l2_loopback_config_format = "ii32sIIIIiiii" + + +# V4L2LOOPBACK_CTL_ADD = 0x40487e01 +# use legacy numbers since the change was recent +V4L2LOOPBACK_CTL_ADD = 0x4C80 + + +def register_device(name): + ctrl_fd = os.open("/dev/v4l2loopback", os.O_RDWR) + try: + if not name: + name = "Qubes Video Companion" + # arg is v4l2_loopback_config config struct + conf = bytearray( + struct.pack( + v4l2_loopback_config_format, + -1, # output_nr (auto) + -1, # unused + name.encode(), # card_label + 0, # min_width + 0, # max_width + 0, # min_height + 0, # max_height + 0, # max_buffers + 0, # max_openers + 0, # debug + 0, # announce_all_caps + ) + ) + ret = fcntl.ioctl(ctrl_fd, V4L2LOOPBACK_CTL_ADD, conf) + return ret + finally: + os.close(ctrl_fd) + + +def main(argv): + name = None + if len(argv) == 2: + name = f"QVC - {argv[1]}" + elif len(argv) != 1: + raise RuntimeError("Invalid arguments - usage: setup.py [name]") + if not os.path.exists("/dev/v4l2loopback"): + subprocess.check_call( + ["sudo", "--non-interactive", "modprobe", "v4l2loopback"] + ) + dev_nr = register_device(name) + print(f"/dev/video{dev_nr}") + + +if __name__ == "__main__": + main(sys.argv) diff --git a/receiver/setup.sh b/receiver/setup.sh deleted file mode 100755 index 8658e21..0000000 --- a/receiver/setup.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/bin/bash - -# Copyright (C) 2021 Elliot Killick -# Licensed under the MIT License. See LICENSE file for details. - -[ "$DEBUG" == 1 ] && set -x - -set -E # Enable function inheritance of traps -trap exit ERR - -# shellcheck source=receiver/common.sh -source /usr/share/qubes-video-companion/receiver/common.sh - -if ! test_v4l2loopback; then - echo "The v4l2loopback kernel module is not installed. Please run the following script to install it: /usr/share/qubes-video-companion/scripts/v4l2loopback/install.sh" - exit 1 -fi - -# exclusive_caps=1: Some applications such as Cheese and Chromium won't detect the video device if it's set to zero -sudo modprobe v4l2loopback card_label="Qubes Video Companion" exclusive_caps=1 - -# For some reason, AppVMs based off my self-made "kali" qube (which itself is based off the "debian-10" TemplateVM) that are using the 5.x Qubes Linux kernel no longer has the user permitting ACL (or any ACL for that matter) on /dev/video* devices causing a permission error when attempting to write video to the device -# As a workaround, we set the ACL ourselves in case it isn't already applied -# This issue does not occur on the Fedora or Debian AppVMs using the 5.x Qubes Linux kernel, more research is required -sudo setfacl -m group:qubes:rw /dev/video0 diff --git a/rpm_spec/qubes-video-companion.spec.in b/rpm_spec/qubes-video-companion.spec.in index 58967d1..892715d 100644 --- a/rpm_spec/qubes-video-companion.spec.in +++ b/rpm_spec/qubes-video-companion.spec.in @@ -103,10 +103,9 @@ This package contains the video-receiving portion of Qubes Video Companion. %{_sysconfdir}/dkms/v4l2loopback.conf %{_mandir}/man1/qubes-video-companion.1.gz %{_bindir}/qubes-video-companion -%{_datadir}/qubes-video-companion/receiver/setup.sh +%{_datadir}/qubes-video-companion/receiver/setup.py %{_datadir}/qubes-video-companion/receiver/receiver.py -%{_datadir}/qubes-video-companion/receiver/destroy.sh -%{_datadir}/qubes-video-companion/receiver/common.sh +%{_datadir}/qubes-video-companion/receiver/destroy.py /usr/share/applications/qubes-video-companion-webcam.desktop /usr/share/applications/qubes-video-companion-screenshare.desktop From 60771d01db87cb2c1876494f5bc1539b878bb572 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Marczykowski-G=C3=B3recki?= Date: Wed, 12 Nov 2025 21:04:23 +0100 Subject: [PATCH 2/4] Add module configuration to reduce root usage by qubes-video-companion Set /dev/v4l2loopback permission so users in `qubes` group have access. And also prevent creating initial /dev/video0 now that it's created dynamically. Note that /dev/video* don't need any manual treatment, as default udev rules already give local user access to it. One still needs sudo to load the module. QubesOS/qubes-issues#10163 --- Makefile | 2 ++ receiver/qubes-video-companion.modprobe | 1 + receiver/qubes-video-companion.rules | 1 + receiver/setup.py | 4 ++++ rpm_spec/qubes-video-companion.spec.in | 2 ++ 5 files changed, 10 insertions(+) create mode 100644 receiver/qubes-video-companion.modprobe create mode 100644 receiver/qubes-video-companion.rules diff --git a/Makefile b/Makefile index 1be2a1b..57be04c 100644 --- a/Makefile +++ b/Makefile @@ -37,6 +37,8 @@ install-vm: install-both $(INSTALL_PROGRAM) receiver/$(PKGNAME) $(DESTDIR)$(BINDIR) $(INSTALL_DIR) $(DESTDIR)$(DATADIR)/$(PKGNAME)/receiver $(INSTALL_PROGRAM) receiver/setup.py receiver/receiver.py receiver/destroy.py $(DESTDIR)$(DATADIR)/$(PKGNAME)/receiver + $(INSTALL_DATA) receiver/qubes-video-companion.rules $(DESTDIR)/usr/lib/udev/rules.d/80-qubes-video-companion.rules + $(INSTALL_DATA) receiver/qubes-video-companion.modprobe $(DESTDIR)/usr/lib/modprobe.d/qubes-video-companion.conf $(INSTALL_DIR) $(DESTDIR)$(SYSCONFDIR)/qubes/rpc-config echo 'wait-for-session=1' > $(DESTDIR)$(SYSCONFDIR)/qubes/rpc-config/qvc.Webcam echo 'wait-for-session=1' > $(DESTDIR)$(SYSCONFDIR)/qubes/rpc-config/qvc.ScreenShare diff --git a/receiver/qubes-video-companion.modprobe b/receiver/qubes-video-companion.modprobe new file mode 100644 index 0000000..b948734 --- /dev/null +++ b/receiver/qubes-video-companion.modprobe @@ -0,0 +1 @@ +options v4l2loopback devices=0 diff --git a/receiver/qubes-video-companion.rules b/receiver/qubes-video-companion.rules new file mode 100644 index 0000000..1c3e7b4 --- /dev/null +++ b/receiver/qubes-video-companion.rules @@ -0,0 +1 @@ +KERNEL=="v4l2loopback", GROUP="qubes", MODE="0660" diff --git a/receiver/setup.py b/receiver/setup.py index 20bb169..d8b1808 100644 --- a/receiver/setup.py +++ b/receiver/setup.py @@ -101,6 +101,10 @@ def main(argv): subprocess.check_call( ["sudo", "--non-interactive", "modprobe", "v4l2loopback"] ) + # wait for udev to apply permission + subprocess.check_call( + ["udevadm", "wait", "--settle", "/dev/v4l2loopback"] + ) dev_nr = register_device(name) print(f"/dev/video{dev_nr}") diff --git a/rpm_spec/qubes-video-companion.spec.in b/rpm_spec/qubes-video-companion.spec.in index 892715d..a5aa5f2 100644 --- a/rpm_spec/qubes-video-companion.spec.in +++ b/rpm_spec/qubes-video-companion.spec.in @@ -108,6 +108,8 @@ This package contains the video-receiving portion of Qubes Video Companion. %{_datadir}/qubes-video-companion/receiver/destroy.py /usr/share/applications/qubes-video-companion-webcam.desktop /usr/share/applications/qubes-video-companion-screenshare.desktop +/usr/lib/udev/rules.d/80-qubes-video-companion.rules +/usr/lib/modprobe.d/qubes-video-companion.conf %package docs Summary: Documentation for qubes-video-companion From 0f32a4b7e8d8909a79b88aeea665afba1f899280 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Marczykowski-G=C3=B3recki?= Date: Thu, 13 Nov 2025 01:37:37 +0100 Subject: [PATCH 3/4] Explicitly allow 'sudo modprobe v4l2loopback' Make it work also without password-less root enabled. --- Makefile | 1 + receiver/qubes-video-companion.sudoers | 1 + rpm_spec/qubes-video-companion.spec.in | 1 + 3 files changed, 3 insertions(+) create mode 100644 receiver/qubes-video-companion.sudoers diff --git a/Makefile b/Makefile index 57be04c..c40e5ff 100644 --- a/Makefile +++ b/Makefile @@ -39,6 +39,7 @@ install-vm: install-both $(INSTALL_PROGRAM) receiver/setup.py receiver/receiver.py receiver/destroy.py $(DESTDIR)$(DATADIR)/$(PKGNAME)/receiver $(INSTALL_DATA) receiver/qubes-video-companion.rules $(DESTDIR)/usr/lib/udev/rules.d/80-qubes-video-companion.rules $(INSTALL_DATA) receiver/qubes-video-companion.modprobe $(DESTDIR)/usr/lib/modprobe.d/qubes-video-companion.conf + $(INSTALL_DATA) receiver/qubes-video-companion.sudoers $(DESTDIR)/etc/sudoers.d/qubes-video-companion $(INSTALL_DIR) $(DESTDIR)$(SYSCONFDIR)/qubes/rpc-config echo 'wait-for-session=1' > $(DESTDIR)$(SYSCONFDIR)/qubes/rpc-config/qvc.Webcam echo 'wait-for-session=1' > $(DESTDIR)$(SYSCONFDIR)/qubes/rpc-config/qvc.ScreenShare diff --git a/receiver/qubes-video-companion.sudoers b/receiver/qubes-video-companion.sudoers new file mode 100644 index 0000000..bbe696c --- /dev/null +++ b/receiver/qubes-video-companion.sudoers @@ -0,0 +1 @@ +%qubes ALL = (root) NOPASSWD: /usr/sbin/modprobe v4l2loopback diff --git a/rpm_spec/qubes-video-companion.spec.in b/rpm_spec/qubes-video-companion.spec.in index a5aa5f2..cbf018d 100644 --- a/rpm_spec/qubes-video-companion.spec.in +++ b/rpm_spec/qubes-video-companion.spec.in @@ -110,6 +110,7 @@ This package contains the video-receiving portion of Qubes Video Companion. /usr/share/applications/qubes-video-companion-screenshare.desktop /usr/lib/udev/rules.d/80-qubes-video-companion.rules /usr/lib/modprobe.d/qubes-video-companion.conf +/etc/sudoers.d/qubes-video-companion %package docs Summary: Documentation for qubes-video-companion From a097f71ad11e3961b2f4c2216cd205f190f0a0ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Marczykowski-G=C3=B3recki?= Date: Fri, 14 Nov 2025 13:52:49 +0100 Subject: [PATCH 4/4] tests: save compared images if they don't match Allow seeing what is different. --- tests/qvctests/integ.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/qvctests/integ.py b/tests/qvctests/integ.py index fb41da6..6b8a117 100644 --- a/tests/qvctests/integ.py +++ b/tests/qvctests/integ.py @@ -91,6 +91,11 @@ def test_010_screenshare(self): source_image = self.capture_from_screen(self.source) destination_image = self.capture_from_video(self.view) diff = self.compare_images(source_image, destination_image) + if diff >= 2.0: + with open(f"/tmp/window-dump-{self.id()}-source", "wb") as f: + f.write(source_image) + with open(f"/tmp/window-dump-{self.id()}-dest", "wb") as f: + f.write(destination_image) self.assertLess(diff, 2.0) self.click_stop(self.source, 'screenshare') # wait for device to disappear, or a timeout @@ -171,6 +176,11 @@ def test_020_webcam(self): source_image = self.capture_from_video(self.source, ",width=640,height=480") source_image = self.apply_mask(source_image) diff = self.compare_images(source_image, destination_image) + if diff >= 2.5: + with open(f"/tmp/window-dump-{self.id()}-source", "wb") as f: + f.write(source_image) + with open(f"/tmp/window-dump-{self.id()}-dest", "wb") as f: + f.write(destination_image) self.assertLess(diff, 2.5)