diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 7959fa4..a9b8e6c 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -12,6 +12,7 @@ checks:pylint: before_script: - sudo dnf install -y python3-gobject gtk3 python3-pylint script: + - export PYTHONPATH=ci/test-packages - pylint-3 $(find sender receiver scripts -type f -name '*.py') include: diff --git a/Makefile b/Makefile index c40e5ff..2c23d0c 100644 --- a/Makefile +++ b/Makefile @@ -9,6 +9,7 @@ DATADIR ?= /usr/share SYSCONFDIR ?= /etc QREXECSERVICEDIR ?= $(SYSCONFDIR)/qubes-rpc QREXECPOLICYDIR ?= $(SYSCONFDIR)/qubes/policy.d +UDEVDIR ?= /usr/lib/udev/rules.d PYTHON ?= python3 INSTALL_DIR = install -d -- @@ -40,6 +41,7 @@ install-vm: install-both $(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_DATA) receiver/qubes-video-companion-webcam@.service $(DESTDIR)/usr/lib/systemd/system/qubes-video-companion-webcam@.service $(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 @@ -56,9 +58,17 @@ install-dom0: install-both install-policy install-tests install-both: $(INSTALL_DIR) $(DESTDIR)$(QREXECSERVICEDIR) - $(INSTALL_PROGRAM) qubes-rpc/services/qvc.Webcam qubes-rpc/services/qvc.ScreenShare $(DESTDIR)$(QREXECSERVICEDIR) + $(INSTALL_PROGRAM) qubes-rpc/services/qvc.Webcam \ + qubes-rpc/services/qvc.ScreenShare \ + $(DESTDIR)$(QREXECSERVICEDIR) + $(INSTALL_PROGRAM) qubes-rpc/services/qvc.WebcamAttach \ + qubes-rpc/services/qvc.WebcamDetach \ + $(DESTDIR)$(QREXECSERVICEDIR) $(INSTALL_DIR) $(DESTDIR)$(DATADIR)/$(PKGNAME)/sender $(INSTALL_PROGRAM) sender/*.py $(DESTDIR)$(DATADIR)/$(PKGNAME)/sender + $(INSTALL_PROGRAM) scripts/webcam-formats/webcam_formats.py $(DESTDIR)$(DATADIR)/$(PKGNAME)/sender/ + $(INSTALL_PROGRAM) scripts/udev-handler $(DESTDIR)$(DATADIR)/$(PKGNAME)/sender/ + $(INSTALL_PROGRAM) sender/udev.rules $(DESTDIR)$(UDEVDIR)/80-qubes-video-companion-sender.rules $(INSTALL_DIR) $(DESTDIR)$(DATADIR)/doc/$(PKGNAME) $(INSTALL_DATA) README.md doc/pipeline.md $(DESTDIR)$(DATADIR)/doc/$(PKGNAME) $(INSTALL_DIR) $(DESTDIR)$(DATADIR)/doc/$(PKGNAME)/visualizations diff --git a/ci/test-packages/qubesdb.py b/ci/test-packages/qubesdb.py new file mode 100644 index 0000000..5cd8880 --- /dev/null +++ b/ci/test-packages/qubesdb.py @@ -0,0 +1,12 @@ +class QubesDB: + def read(self, key): + return b'testvm' + + def rm(self, key): + pass + + def write(self, key, value): + pass + +class Error(Exception): + pass diff --git a/core3ext/pyproject.toml b/core3ext/pyproject.toml new file mode 100644 index 0000000..a41935a --- /dev/null +++ b/core3ext/pyproject.toml @@ -0,0 +1,21 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "qvc" +version = "4.3" +description = "QVC plugin for qubes-core-admin" +license = { text = "MIT" } + +[project.urls] +Homepage = "https://www.qubes-os.org/" +Documentation = "https://readthedocs.org" +Repository = "https://github.com/QubesOS/qubes-video-companion.git" +"Bug Tracker" = "https://github.com/QubesOS/qubes-issues/issues" + +[project.entry-points."qubes.ext"] +webcam = "qvc:WebcamDeviceExtension" + +[project.entry-points."qubes.devices"] +webcam = "qvc:WebcamDevice" diff --git a/core3ext/qvc/__init__.py b/core3ext/qvc/__init__.py new file mode 100644 index 0000000..263b7d5 --- /dev/null +++ b/core3ext/qvc/__init__.py @@ -0,0 +1,464 @@ +# Copyright (C) 2025 Marek Marczykowski-Górecki +# +# Licensed under the MIT License. See LICENSE file for details. +import asyncio +import contextlib +import os +import re +import string +import subprocess +from typing import Optional, List + +import qubes.device_protocol +import qubes.ext +from qubes.device_protocol import DeviceInterface, Port +from qubes.exc import QubesException +from qubes.ext import utils +from qubes.utils import sanitize_stderr_for_log + +name_re = re.compile(r"\A[a-z0-9-]{1,12}\Z") +device_re = re.compile(r"\A[a-z0-9/-]{1,64}\Z") +connected_to_re = re.compile(rb"^[a-zA-Z][a-zA-Z0-9_.-]*$") +format_re = re.compile(r"\A^[1-9][0-9]{0,3}x[1-9][0-9]{0,3}x[1-9][0-9]{0,2}\Z") + +class WebcamDevice(qubes.device_protocol.DeviceInfo): + def __init__(self, port: qubes.device_protocol.Port): + if port.devclass != "webcam": + raise qubes.exc.QubesValueError( + f"Incompatible device class for input port: {port.devclass}" + ) + + # init parent class + super().__init__(port) + + self._qdb_path = f"/webcam-devices/{port.port_id}" + + self._formats = None + + @property + def interfaces(self) -> List[DeviceInterface]: + return [DeviceInterface("u0e0200")] + + @property + def vendor(self) -> str: + if self.parent_device: + return self.parent_device.vendor + return "unknown" + + @property + def manufacturer(self) -> str: + if self.parent_device: + return self.parent_device.manufacturer + return "unknown" + + @property + def product(self) -> str: + if self.parent_device: + return self.parent_device.product + return "unknown" + + @property + def name(self) -> str: + if self.parent_device: + return f"Camera ({self.parent_device.name})" + return "Camera" + + @property + def description(self) -> str: + if self.parent_device: + return f"Camera ({self.parent_device.description})" + return "Camera" + + @property + def serial(self) -> str: + if self.parent_device: + return self.parent_device.serial + return "unknown" + + @property + def device_id(self) -> str: + if self.parent_device: + return self.parent_device.device_id + return super().device_id + + @staticmethod + def _sanitize( + untrusted_parent: bytes, + safe_chars: str = string.ascii_letters + + string.digits + + string.punctuation, + ) -> str: + untrusted_device_desc = untrusted_parent.decode( + "ascii", errors="ignore" + ) + return "".join( + c if c in set(safe_chars) else "_" for c in untrusted_device_desc + ) + + def _get_parent_device(self, port: Port): + if not port.backend_domain or not port.backend_domain.is_running(): + return None + untrusted_parent_info = port.backend_domain.untrusted_qdb.read( + f"/webcam-devices/{port.port_id}/parent" + ) + if not untrusted_parent_info: + return None + parent_devclass, parent_ident = self._sanitize( + untrusted_parent_info + ).split(":", maxsplit=1) + if not parent_ident: + return None + try: + return port.backend_domain.devices[parent_devclass][ + parent_ident + ] + except KeyError: + return qubes.device_protocol.UnknownDevice( + qubes.device_protocol.Port( + port.backend_domain, parent_ident, + devclass=parent_devclass + ) + ) + @property + def parent_device(self) -> Optional[qubes.device_protocol.DeviceInfo]: + """ + The parent device, if any. + """ + if self._parent is None: + self._parent = self._get_parent_device(self.port) + return self._parent + + @property + def attachment(self): + if not self.backend_domain.is_running(): + return None + untrusted_connected_to = self.backend_domain.untrusted_qdb.read( + self._qdb_path + "/connected-to" + ) + if not untrusted_connected_to: + return None + if not connected_to_re.match(untrusted_connected_to): + self.backend_domain.log.warning( + f"Device {self.port_id} has invalid chars in connected-to " + "property" + ) + return None + untrusted_connected_to = untrusted_connected_to.decode( + "ascii", errors="strict" + ) + try: + connected_to = self.backend_domain.app.domains[ + untrusted_connected_to + ] + except KeyError: + self.backend_domain.log.warning( + f"Device {self.port_id} has invalid VM name in connected-to " + f"property: {untrusted_connected_to}" + ) + return None + return connected_to + + @property + def formats(self): + if self._formats is None: + untrusted_formats = self.backend_domain.untrusted_qdb.multiread( + self._qdb_path + "/formats/" + ) + formats = [] + for _, untrusted_format in untrusted_formats.items(): + untrusted_format = untrusted_format.decode("ascii", errors="strict") + if not format_re.match(untrusted_format): + self.backend_domain.log.warning("Invalid format") + continue + formats.append(untrusted_format) + self._formats = " ".join(formats) + return self._formats + + @property + def data(self): + """Return extra attributes for serialization""" + return { + "formats": self.formats, + } + + @data.setter + def data(self, data): + """Setter for 'data' attribute, to make DeviceInfo class happy""" + # not really used, but DeviceInfo constructor tries to set it, + # so accept (and ignore) empty value + if data == {}: + return + raise AttributeError("read-only attribute data") + + +class QVCNotInstalled(QubesException): + pass + + +@contextlib.contextmanager +def allow_qrexec_call(service, arg, source, dest): + fname = f"/run/qubes/policy.d/10-qvc-{hash((service, arg, source, dest))}.policy" + with open(fname, "x") as policy: + policy.write(f"{service} {arg} {source} {dest} allow\n") + try: + yield + finally: + os.unlink(fname) + + +class WebcamDeviceExtension(qubes.ext.Extension): + @qubes.ext.handler("domain-init", "domain-load") + def on_domain_init_load(self, vm, event): + """Initialize watching for changes""" + # pylint: disable=unused-argument + vm.watch_qdb_path("/webcam-devices") + if vm.app.vmm.offline_mode: + self.devices_cache[vm.name] = {} + return + if event == "domain-load": + # avoid building a cache on domain-init, as it isn't fully set yet, + # and definitely isn't running yet + current_devices = { + dev.port_id: dev.attachment + for dev in self.on_device_list_webcam(vm, None) + } + self.devices_cache[vm.name] = current_devices + else: + self.devices_cache[vm.name] = {} + + async def attach_and_notify(self, vm, assignment): + # bypass DeviceCollection logic preventing double attach + device = assignment.device + if assignment.mode.value == "ask-to-attach": + allowed = await utils.confirm_device_attachment( + device, {vm: assignment} + ) + allowed = allowed.strip() + if vm.name != allowed: + return + await self.on_device_attach_webcam( + vm, "device-pre-attach:webcam", device, assignment.options + ) + await vm.fire_event_async( + "device-attach:webcam", device=device, options=assignment.options + ) + + def ensure_detach(self, vm, port): + """ + Run this method if device is no longer detected. + + No additional action required in case of webcam devices. + """ + pass + + @qubes.ext.handler("domain-qdb-change:/webcam-devices") + def on_qdb_change(self, vm, event, path): + """A change in QubesDB means a change in a device list.""" + # pylint: disable=unused-argument + current_devices = dict( + (dev.port_id, dev.attachment) + for dev in self.on_device_list_webcam(vm, None) + ) + utils.device_list_change(self, current_devices, vm, path, WebcamDevice) + + @staticmethod + def device_get(vm, port_id): + untrusted_qubes_device_attrs = vm.untrusted_qdb.list( + "/webcam-devices/{}/".format(port_id) + ) + if not untrusted_qubes_device_attrs: + return None + return WebcamDevice( + qubes.device_protocol.Port( + backend_domain=vm, port_id=port_id, devclass="webcam" + ) + ) + + @qubes.ext.handler("device-list:webcam") + def on_device_list_webcam(self, vm, event): + if not vm.is_running() or not hasattr(vm, "untrusted_qdb"): + return + untrusted_devices = vm.untrusted_qdb.list("/webcam-devices/") + + untrusted_idents = set(untrusted_path.split("/", 3)[2] + for untrusted_path in untrusted_devices) + for untrusted_ident in untrusted_idents: + if not name_re.match(untrusted_ident): + msg = ( + "%s vm's device path name contains unsafe characters. " + "Skipping it." + ) + vm.log.warning(msg) + continue + + port_id = untrusted_ident + + device_info = self.device_get(vm, port_id) + if device_info: + yield device_info + + @qubes.ext.handler("device-get:webcam") + def on_device_get_webcam(self, vm, event, port_id): + # pylint: disable=unused-argument + if not vm.is_running(): + return + + if vm.untrusted_qdb.list("/webcam-devices/" + port_id): + yield WebcamDevice(Port(vm, port_id, "webcam")) + + @staticmethod + def get_all_devices(app): + for vm in app.domains: + if not vm.is_running() or not hasattr(vm, "devices"): + continue + + for dev in vm.devices["webcam"]: + if isinstance(dev, WebcamDevice): + yield dev + + @qubes.ext.handler("device-list-attached:webcam") + def on_device_list_attached(self, vm, event, **kwargs): + # pylint: disable=unused-argument + if not vm.is_running(): + return + + for dev in self.get_all_devices(vm.app): + if dev.attachment == vm: + yield (dev, {}) + + @qubes.ext.handler("device-pre-attach:webcam") + async def on_device_pre_attach_webcam(self, vm, event, device, options): + # pylint: disable=unused-argument + + arg = device.port_id + + if options: + for option, value in options.items(): + if option == "format": + if not format_re.match(value): + raise QubesException("Invalid format value") + arg += "+" + value.replace("x", "+") + else: + raise QubesException("Unsupported option: '{}'".format(option)) + + if not vm.is_running() or vm.qid == 0: + # print(f"Qube is not running, skipping attachment of {device}", + # file=sys.stderr) + return + + assert isinstance(device, WebcamDevice) + + if device.attachment: + raise qubes.exc.DeviceAlreadyAttached( + f"Device {device} already attached to {device.attachment}" + ) + + if not vm.features.check_with_template("supported-rpc.qvc.WebcamAttach", False): + raise QVCNotInstalled("qubes-video-companion not installed in the VM") + + # update the cache before the call, to avoid sending duplicated events + # (one on qubesdb watch and the other by the caller of this method) + self.devices_cache[device.backend_domain.name][device.port_id] = vm + + # set qrexec policy to allow this device + with allow_qrexec_call("qvc.Webcam", "+" + arg, f"uuid:{vm.uuid}", f"uuid:{device.backend_domain.uuid}"): + # and actual attach + try: + await vm.run_service_for_stdio( + "qvc.WebcamAttach", + user="root", + input=f"{device.backend_domain.name} " + f"{arg}\n".encode(), + ) + except subprocess.CalledProcessError as e: + # pylint: disable=raise-missing-from + if e.returncode == 127: + raise QVCNotInstalled("qubes-video-companion not installed in the VM") + raise QubesException( + f"Device attach failed: {sanitize_stderr_for_log(e.output)}" + f" {sanitize_stderr_for_log(e.stderr)}" + ) + + @qubes.ext.handler("device-pre-detach:webcam") + async def on_device_detach_webcam(self, vm, event, port): + # pylint: disable=unused-argument + if not vm.is_running() or vm.qid == 0: + return + + for attached, _options in self.on_device_list_attached(vm, event): + if attached.port == port: + break + else: + raise QubesException( + f"Device {port} not connected to VM {vm.name}" + ) + + # update the cache before the call, to avoid sending duplicated events + # (one on qubesdb watch and the other by the caller of this method) + backend = attached.backend_domain + self.devices_cache[backend.name][attached.port_id] = None + + try: + await backend.run_service_for_stdio( + f"qvc.WebcamDetach+{attached.port_id}", + user="root", + ) + except subprocess.CalledProcessError as e: + # pylint: disable=raise-missing-from + raise QubesException( + f"Device detach failed: {sanitize_stderr_for_log(e.output)}" + f" {sanitize_stderr_for_log(e.stderr)}" + ) + + @qubes.ext.handler("device-pre-assign:webcam") + async def on_device_assign_webcam(self, vm, event, device, options): + # pylint: disable=unused-argument + # validate options + if options: + for option, value in options.items(): + if option == "format": + if not format_re.match(value): + raise QubesException("Invalid format value") + else: + raise QubesException("Unsupported option: '{}'".format(option)) + + @qubes.ext.handler("domain-start") + async def on_domain_start(self, vm, _event, **_kwargs): + # pylint: disable=unused-argument + to_attach = {} + assignments = vm.devices["webcam"].get_assigned_devices() + # the most specific assignments first + for assignment in reversed(sorted(assignments)): + for device in assignment.devices: + if isinstance(device, qubes.device_protocol.UnknownDevice): + continue + if device.attachment: + continue + if not assignment.matches(device): + vm.log.warning( + "Unrecognized identity, skipping attachment of device " + f"from the port {assignment}", + ) + continue + # chose first assignment (the most specific) and ignore rest + if device not in to_attach: + # make it unique + to_attach[device] = assignment.clone(device=device) + in_progress = set() + for assignment in to_attach.values(): + in_progress.add( + asyncio.ensure_future(self.attach_and_notify(vm, assignment)) + ) + if in_progress: + await asyncio.wait(in_progress) + + @qubes.ext.handler("domain-shutdown") + async def on_domain_shutdown(self, vm, _event, **_kwargs): + # pylint: disable=unused-argument + vm.fire_event("device-list-change:webcam") + utils.device_list_change(self, {}, vm, None, WebcamDevice) + + @qubes.ext.handler("qubes-close", system=True) + def on_qubes_close(self, app, event): + # pylint: disable=unused-argument + self.devices_cache.clear() diff --git a/core3ext/qvc/aa/__init__.py b/core3ext/qvc/aa/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/qubes-rpc/services/qvc.WebcamAttach b/qubes-rpc/services/qvc.WebcamAttach new file mode 100644 index 0000000..3eb7713 --- /dev/null +++ b/qubes-rpc/services/qvc.WebcamAttach @@ -0,0 +1,11 @@ +#!/bin/sh -- +set -eu + +read -r domain arg +cursor=$(journalctl -n 0 -q --show-cursor | cut -d ' ' -f 3-) +unit="qubes-video-companion-webcam@$domain+$arg.service" +if ! systemctl -q start "$unit"; then + journalctl --no-pager --after-cursor="$cursor" --unit="$unit" --output=cat | + grep -v 'service: Main process exited\|service: Failed with result\|^Starting' >&2 + exit 1 +fi diff --git a/qubes-rpc/services/qvc.WebcamDetach b/qubes-rpc/services/qvc.WebcamDetach new file mode 100644 index 0000000..a6ddc9a --- /dev/null +++ b/qubes-rpc/services/qvc.WebcamDetach @@ -0,0 +1,21 @@ +#!/bin/sh + +portid="$1" + +if [ "$portid" != "dev-video0" ]; then + echo "Unsupported port id!" >&2 + exit 2 +fi + +pidfile="/run/qubes/qvc-webcam-$portid" +if [ -r "$pidfile" ]; then + pid="$(cat "$pidfile")" + # safety check, just in case the process exited without cleaning up + if ! grep -q webcam.py "/proc/$pid/cmdline" 2>/dev/null; then + # can't kill... + rm -f "$pidfile" + echo "Can't find QVC process (PID $pid), already exited?" >&2 + exit 1 + fi + kill -- "$pid" +fi diff --git a/receiver/qubes-video-companion b/receiver/qubes-video-companion index 37ced9a..89a8e6c 100755 --- a/receiver/qubes-video-companion +++ b/receiver/qubes-video-companion @@ -12,13 +12,15 @@ unset GETOPT_COMPATIBLE name=${0##*/} usage() { - echo "Usage: $name [--resolution=[WIDTHxHEIGHTxFPS]] [--] webcam|screenshare [destination qube]" >&2 + echo "Usage: $name [--instance-arg=...] [--resolution=[WIDTHxHEIGHTxFPS]] [--] webcam|screenshare [destination qube]" >&2 echo "Resolution example: 1920x1080x60" + echo "--instance-arg is used internally when started via systemd" exit "$1" } resolution= -opts=$(getopt "--name=$name" --longoptions=resolution:,help -- r: "$@") || exit +instance_arg= +opts=$(getopt "--name=$name" --longoptions=resolution:,help,instance-arg: -- r: "$@") || exit eval "set -- $opts" while :; do case $1 in @@ -31,6 +33,14 @@ while :; do resolution=${resolution//x/+} shift 2 ;; + --instance-arg) + if [[ -z "$2" ]]; then + echo "$name: missing instance arg value" >&2 + usage 1 >&2 + fi + instance_arg="${2//\\x2b/+}" + shift 2 + ;; -h|--help) usage 0;; --) shift; break;; *) exit 1;; # cannot happen @@ -41,6 +51,17 @@ if [ "$#" -gt 2 ] || [ "$#" -lt 1 ]; then usage 1 >&2; fi video_source="$1" qube=${2-'@default'} +if [[ "$qube" != "@default" ]] && [[ -n "$instance_arg" ]]; then + echo "Cannot use --instance-arg together with destination qube" >&2 + exit 1 +fi + +if [[ -n "$instance_arg" ]]; then + qube="${instance_arg%%+*}" + # in reality full qrexec arg + resolution="${instance_arg#*+}" +fi + case "$video_source" in webcam) qvc_service="qvc.Webcam" diff --git a/receiver/qubes-video-companion-webcam@.service b/receiver/qubes-video-companion-webcam@.service new file mode 100644 index 0000000..8d7d3f1 --- /dev/null +++ b/receiver/qubes-video-companion-webcam@.service @@ -0,0 +1,7 @@ +[Unit] +Description=Qubes Video Companion - webcam + +[Service] +Type=notify +ExecStart=/usr/bin/qubes-video-companion "--instance-arg=%i" webcam +NotifyAccess=all diff --git a/receiver/receiver.py b/receiver/receiver.py index ebc95f0..2d2bf48 100644 --- a/receiver/receiver.py +++ b/receiver/receiver.py @@ -5,11 +5,21 @@ # Licensed under the MIT License. See LICENSE file for details. import sys +import socket import struct import os from typing import NoReturn +def sdnotify(msg): + sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) + addr = os.getenv("NOTIFY_SOCKET") + if addr[0] == "@": + addr = '\0' + addr[1:] + sock.connect(addr) + sock.sendall(msg) + sock.close() + def main(argv) -> NoReturn: dev_path = "/dev/video0" if len(argv) == 2: @@ -21,6 +31,8 @@ def main(argv) -> NoReturn: width, height, fps = read_video_parameters() + if "NOTIFY_SOCKET" in os.environ: + sdnotify(b"READY=1") print( "Receiving video stream at {}x{} {} FPS...".format(width, height, fps), file=sys.stderr, diff --git a/rpm_spec/qubes-video-companion-dom0.spec.in b/rpm_spec/qubes-video-companion-dom0.spec.in index fb3e24b..c585a1d 100644 --- a/rpm_spec/qubes-video-companion-dom0.spec.in +++ b/rpm_spec/qubes-video-companion-dom0.spec.in @@ -17,6 +17,8 @@ BuildArch: noarch BuildRequires: make BuildRequires: python3-setuptools BuildRequires: python3-devel +BuildRequires: python3-pip +BuildRequires: python3-wheel Requires: gstreamer1-plugins-good Requires: v4l-utils @@ -56,24 +58,45 @@ necessary for dom0. %define __python %{__pythonX} %make_build +%if 0%{?fedora} >= 41 +pushd core3ext +%pyproject_wheel +popd +%endif + %install rm -rf $RPM_BUILD_ROOT make DESTDIR=%{?buildroot} install-dom0 install-license +%if 0%{?fedora} >= 41 +pushd core3ext +%pyproject_install +popd +%endif + %files %{_licensedir}/qubes-video-companion/LICENSE %{_docdir}/qubes-video-companion/README.md %{_docdir}/qubes-video-companion/pipeline.md %{_docdir}/qubes-video-companion/visualizations/* %{_sysconfdir}/qubes-rpc/qvc.Webcam +%{_sysconfdir}/qubes-rpc/qvc.WebcamAttach +%{_sysconfdir}/qubes-rpc/qvc.WebcamDetach %{_sysconfdir}/qubes-rpc/qvc.ScreenShare %{_sysconfdir}/qubes/policy.d/90-default-video-companion.policy +/usr/lib/udev/rules.d/80-qubes-video-companion-sender.rules %{_datadir}/qubes-video-companion/sender/service.py %{_datadir}/qubes-video-companion/sender/webcam.py +%{_datadir}/qubes-video-companion/sender/webcam_formats.py %{_datadir}/qubes-video-companion/sender/screenshare.py %{_datadir}/qubes-video-companion/sender/tray_icon.py +%{_datadir}/qubes-video-companion/sender/udev-handler %{python3_sitelib}/qvctests %{python3_sitelib}/qvctests-*.egg-info +%if 0%{?fedora} >= 41 +%{python3_sitelib}/qvc/ +%{python3_sitelib}/qvc-*.dist-info/ +%endif %changelog @CHANGELOG@ diff --git a/rpm_spec/qubes-video-companion.spec.in b/rpm_spec/qubes-video-companion.spec.in index cbf018d..a8e4266 100644 --- a/rpm_spec/qubes-video-companion.spec.in +++ b/rpm_spec/qubes-video-companion.spec.in @@ -14,6 +14,7 @@ BuildArch: noarch BuildRequires: pandoc BuildRequires: make +BuildRequires: systemd-rpm-macros Requires: qubes-video-companion-sender Requires: qubes-video-companion-receiver Requires: qubes-video-companion-license @@ -62,13 +63,17 @@ This package contains the video-sending portion of Qubes Video Companion. %files sender %{_sysconfdir}/qubes-rpc/qvc.Webcam +%{_sysconfdir}/qubes-rpc/qvc.WebcamDetach %{_sysconfdir}/qubes-rpc/qvc.ScreenShare %{_sysconfdir}/qubes/rpc-config/qvc.Webcam %{_sysconfdir}/qubes/rpc-config/qvc.ScreenShare +/usr/lib/udev/rules.d/80-qubes-video-companion-sender.rules %{_datadir}/qubes-video-companion/sender/service.py %{_datadir}/qubes-video-companion/sender/webcam.py +%{_datadir}/qubes-video-companion/sender/webcam_formats.py %{_datadir}/qubes-video-companion/sender/screenshare.py %{_datadir}/qubes-video-companion/sender/tray_icon.py +%{_datadir}/qubes-video-companion/sender/udev-handler %package receiver Summary: Video receiver part of qubes-video-companion @@ -101,11 +106,13 @@ This package contains the video-receiving portion of Qubes Video Companion. %files receiver %{_sysconfdir}/dkms/v4l2loopback.conf +%{_sysconfdir}/qubes-rpc/qvc.WebcamAttach %{_mandir}/man1/qubes-video-companion.1.gz %{_bindir}/qubes-video-companion %{_datadir}/qubes-video-companion/receiver/setup.py %{_datadir}/qubes-video-companion/receiver/receiver.py %{_datadir}/qubes-video-companion/receiver/destroy.py +%{_unitdir}/qubes-video-companion-webcam@.service /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 diff --git a/scripts/udev-handler b/scripts/udev-handler new file mode 100644 index 0000000..d15e72f --- /dev/null +++ b/scripts/udev-handler @@ -0,0 +1,46 @@ +#!/bin/sh + +set -eu + +portid="dev-$(basename "${DEVPATH}")" + +if [ "$ACTION" = "remove" ]; then + # remove content and the entry itself + qubesdb-rm "/webcam-devices/$portid/" "/webcam-devices/$portid" + # trigger watch + qubesdb-write "/webcam-devices" "" + exit +fi + +if [ "$ACTION" != "add" ] && [ "$ACTION" != "change" ]; then + echo "Unknown action '$ACTION'" >&2 + exit 2 +fi + +if ! [ -e "/sys/$DEVPATH/device" ]; then + # ignore virtual devices created by QVC itself + if grep -q "^QVC -" "/sys/$DEVPATH/name"; then + exit + fi +fi + +parent= + +# for now support only USB parent device +if [ "${ID_BUS-}" = "usb" ]; then + parent_devpath=$(readlink -f "/sys/$DEVPATH/device") + parent_devid="${parent_devpath##*/}" + parent_devid="${parent_devid%%:*}" + parent="usb:$parent_devid" +fi + +if [ -n "$parent" ]; then + qubesdb-write "/webcam-devices/$portid/parent" "$parent" +fi +if [ "$ACTION" = "add" ]; then + qubesdb-write "/webcam-devices/$portid/connected-to" "" +fi +python3 /usr/share/qubes-video-companion/sender/webcam_formats.py --portid="$portid" --device="$DEVNAME" publish + +# trigger watch +qubesdb-write "/webcam-devices" "" diff --git a/scripts/webcam-formats/webcam_formats.py b/scripts/webcam-formats/webcam_formats.py index 9424409..2e58e00 100755 --- a/scripts/webcam-formats/webcam_formats.py +++ b/scripts/webcam-formats/webcam_formats.py @@ -5,8 +5,10 @@ """Get and compare supported webcam formats""" +import argparse import subprocess from collections import OrderedDict +import qubesdb class WebcamFormats: @@ -37,7 +39,7 @@ def __init__(self, formats, video_device="/dev/video0"): while self.__line_idx < self.formats_len: line = self.formats[self.__line_idx] - if line.startswith("Index"): + if line.startswith("["): self.__index() self.__line_idx = self.__line_idx + 1 @@ -51,9 +53,9 @@ def __index(self): while self.__line_idx < self.formats_len: line = self.formats[self.__line_idx] - if line.startswith("Pixel Format"): + if line.startswith("["): # Remove removing surrounding single quotes (') junk - pix_fmt = line.split()[2].replace("'", "") + pix_fmt = line.split()[1].replace("'", "") self.pix_fmt[pix_fmt] = {} if line.startswith("Size"): @@ -137,6 +139,24 @@ def find_best_format(self): self.selected_fps = current_selected_fps break + def publish_formats_info(self, portid): + qdb = qubesdb.QubesDB() + prefix = f"/webcam-devices/{portid}" + # remove old entries + qdb.rm(prefix + "/formats/") + formats = ( + (w, h, fps) + for pix_fmt, size_dict in self.pix_fmt.items() + for (w, h), fps_list in size_dict.items() + for fps in fps_list + ) + format_nr = 0 + for width, height, fps in sorted(set(formats)): + qdb.write(f"{prefix}/formats/{format_nr:02d}", + f"{width}x{height}x{fps}") + format_nr += 1 + + def configure_webcam_best_format(self): """Configure webcam device to use the best format""" @@ -162,3 +182,43 @@ def configure_webcam_best_format(self): ], check=True, ) + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--device", help="/dev/video* device path") + parser.add_argument("--portid", + help="QVC device portid for publishing in qubesdb") + parser.add_argument("action", help="Action to perform; supported: publish") + + args = parser.parse_args() + + if args.action != "publish": + parser.error("Unsupported action: " + args.action) + + if args.device and not args.portid: + parser.error( + "portid mandatory for publish action, if alternative device is set" + ) + + if not args.device: + args.device = "/dev/video0" + args.portid = "dev-video0" + + webcam_supported_formats = ( + subprocess.run( + ["v4l2-ctl", "--device", args.device, "--list-formats-ext"], + stdout=subprocess.PIPE, + check=True, + ) + .stdout.decode("utf-8") + .replace("\t", "") + .splitlines() + ) + + webcam_settings = WebcamFormats(webcam_supported_formats, args.device) + webcam_settings.publish_formats_info(args.portid) + + + +if __name__ == "__main__": + main() diff --git a/sender/service.py b/sender/service.py index 98e8399..10d0b2a 100644 --- a/sender/service.py +++ b/sender/service.py @@ -52,6 +52,8 @@ def start_service(self, target_domain: str, remote_domain: str) -> None: self._tray_icon = tray_icon.TrayIcon(app, icon, msg) + self.record_connect_state(remote_domain) + def video_source(self) -> str: """ Return the video source @@ -86,6 +88,11 @@ def quit(self) -> None: self._element.set_state(Gst.State.NULL) Gtk.main_quit() + def record_connect_state(self, remote_domain) -> None: + """ + Record state of stream, for the disconnect purpose. + """ + def msg_handler(self, _bus: Gst.Bus, msg: Gst.Message) -> None: """Handle pipeline messages""" diff --git a/sender/tray_icon.py b/sender/tray_icon.py index 2e9b897..f3719cd 100644 --- a/sender/tray_icon.py +++ b/sender/tray_icon.py @@ -11,7 +11,8 @@ # GI requires version declaration before importing # pylint: disable=wrong-import-position -from os import _exit +import signal +import sys from typing import NoReturn import gi @@ -76,11 +77,12 @@ def menu(cls, msg, app) -> object: def die(unused_gtk) -> NoReturn: # We do not care about cleaning up properly here; the OS will do # that for us. We *do* care about exiting ASAP. - _exit(0) + sys.exit(0) entry = Gtk.MenuItem.new_with_label("Stop video transmission") entry.connect("activate", die) menu.connect("destroy", die) + signal.signal(signal.SIGTERM, lambda *_args: die(None)) menu.append(entry) menu.show_all() diff --git a/sender/udev.rules b/sender/udev.rules new file mode 100644 index 0000000..1a7c8d8 --- /dev/null +++ b/sender/udev.rules @@ -0,0 +1,2 @@ +# for now publish only /dev/video0 +KERNEL=="video0", RUN+="/usr/share/qubes-video-companion/sender/udev-handler" diff --git a/sender/webcam.py b/sender/webcam.py index ccb7293..92d5c22 100644 --- a/sender/webcam.py +++ b/sender/webcam.py @@ -6,10 +6,13 @@ """Webcam video source module""" +import atexit +import os import sys import re import subprocess from service import Service +import qubesdb class Webcam(Service): @@ -20,23 +23,40 @@ class Webcam(Service): untrusted_requested_fps: int def __init__(self, *, untrusted_arg: str): + self.port_id = "dev-video0" + + if untrusted_arg: + if untrusted_arg.startswith("dev-"): + # first arg may be a port id, and then optional resolution arg + if "+" in untrusted_arg: + untrusted_port_id, untrusted_arg = ( + untrusted_arg.split("+", 1) + ) + else: + untrusted_port_id, untrusted_arg = untrusted_arg, None + + # currently support only a single port: "dev-video0" + if untrusted_port_id != "dev-video0": + print(f"Unsupported webcam port ({untrusted_port_id}), " + "only 'dev-video0' supported", file=sys.stderr) + self.port_id = untrusted_port_id + if untrusted_arg: - untrusted_arg_bytes = untrusted_arg.encode('ascii', 'strict') def parse_int(untrusted_decimal: bytes) -> int: if not ((1 <= len(untrusted_decimal) <= 4) and untrusted_decimal.isdigit() and - untrusted_decimal[0] != b"0"): + untrusted_decimal[0] != "0"): print("Invalid argument " + untrusted_arg + ": bad number", file=sys.stderr) sys.exit(1) return int(untrusted_decimal, 10) - if len(untrusted_arg_bytes) > 14: + if len(untrusted_arg) > 14: # qrexec has already sanitized the argument to some degree, # so this is safe print("Invalid argument " + untrusted_arg + ": too long (limit 14 bytes)", file=sys.stderr) sys.exit(1) - arg_list = untrusted_arg_bytes.split(b"+", 4) + arg_list = untrusted_arg.split("+", 4) if len(arg_list) != 3: print("Invalid argument " + untrusted_arg + ": wrong number of integers (expected 3)", @@ -51,6 +71,8 @@ def parse_int(untrusted_decimal: bytes) -> int: self.untrusted_requested_height = 0 self.untrusted_requested_fps = 0 + self.pidfile = None + Service.main(self) def video_source(self) -> str: @@ -153,6 +175,23 @@ def pipeline(self, width: int, height: int, fps: int, **kwargs): "fdsink", ] + def _cleanup_connect_state(self): + qdb = qubesdb.QubesDB() + qdb.write(f"/webcam-devices/{self.port_id}/connected-to", "") + qdb.write("/webcam-devices", "") + if self.pidfile: + os.unlink(self.pidfile) + + def record_connect_state(self, remote_domain) -> None: + self.pidfile = f"/run/qubes/qvc-webcam-{self.port_id}" + with open(self.pidfile, "w", encoding="ascii") as f_pid: + f_pid.write(f"{os.getpid()}\n") + + qdb = qubesdb.QubesDB() + qdb.write(f"/webcam-devices/{self.port_id}/connected-to", remote_domain) + qdb.write("/webcam-devices", "") + atexit.register(self._cleanup_connect_state) + if __name__ == "__main__": _untrusted_arg = "" diff --git a/tests/qvctests/integ.py b/tests/qvctests/integ.py index 6b8a117..9f1fdf2 100644 --- a/tests/qvctests/integ.py +++ b/tests/qvctests/integ.py @@ -4,10 +4,18 @@ import qubes.tests.extra +try: + import qvc + have_qvc = True +except ImportError: + have_qvc = False + class TC_00_QVCTest(qubes.tests.extra.ExtraTestCase): def setUp(self): super(TC_00_QVCTest, self).setUp() + if "webcam" in self.id() and "whonix" in str(self.template): + self.skipTest("Cannot load 'vivid' module on Whonix") self.source, self.view = self.create_vms( ["source", "view"]) self.source.start() @@ -23,6 +31,15 @@ def wait_for_video0(self, vm): self.assertEqual(retcode, 0, f"Timeout waiting for /dev/video0 in {vm.name}") + def wait_for_webcam(self, vm): + timeout = 30 + for i in range(timeout): + if len(list(vm._vm.devices["webcam"].get_exposed_devices())): + break + self.loop.run_until_complete(asyncio.sleep(0.5)) + else: + self.fail(f"Timeout waiting for webcam device in {vm.name}") + def wait_for_video0_disconnect(self, vm): vm.run( 'for i in `seq 30`; do ' @@ -132,6 +149,12 @@ def fill_black(x1, y1, x2, y2): return bytes(image) + def get_default_video_format(self, vm, device="/dev/video0"): + """Get current format of a video device""" + cmd = f"v4l2-ctl -d {device} --get-fmt-video | grep -o '[0-9]\\+/[0-9]\\+'" + p = vm.run(cmd, passio_popen=True) + stdout, _ = p.communicate() + return stdout.decode().strip().split('/') def test_020_webcam(self): """Webcam test @@ -183,6 +206,96 @@ def test_020_webcam(self): f.write(destination_image) self.assertLess(diff, 2.5) + @unittest.skipIf(not have_qvc, "qvc not installed") + def test_021_webcam_qvm_device(self): + """Webcam test + + source -> view (webcam) + """ + self.loop.run_until_complete(self.wait_for_session(self.source)) + self.view.start() + self.loop.run_until_complete(self.wait_for_session(self.view)) + ret = self.source.run("modprobe vivid", user="root", wait=True) + if ret != 0: + self.skipTest("Cannot load 'vivid' module") + # wait for device to appear, or a timeout + self.wait_for_webcam(self.source) + + p = self.loop.run_until_complete(asyncio.create_subprocess_exec( + "qvm-device", "webcam", "attach", self.view.name, + f"{self.source.name}:dev-video0", + )) + self.loop.run_until_complete(p.wait()) + self.wait_for_video0(self.view) + + destination_image = self.capture_from_video(self.view) + destination_image_dims = self.get_default_video_format(self.view) + destination_image = self.apply_mask(destination_image) + p = self.loop.run_until_complete(asyncio.create_subprocess_exec( + "qvm-device", "webcam", "detach", self.view.name, + f"{self.source.name}:dev-video0" + )) + self.loop.run_until_complete(p.wait()) + self.wait_for_video0_disconnect(self.view) + + # vivid supports only one client at a time, so capture source only + # after QVC disconnects + source_image = self.capture_from_video( + self.source, ",width={},height={}".format(*destination_image_dims) + ) + 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) + + @unittest.skipIf(not have_qvc, "qvc not installed") + def test_022_webcam_qvm_device_resolution(self): + """Webcam test + + source -> view (webcam) + """ + self.loop.run_until_complete(self.wait_for_session(self.source)) + self.view.start() + self.loop.run_until_complete(self.wait_for_session(self.view)) + ret = self.source.run("modprobe vivid", user="root", wait=True) + if ret != 0: + self.skipTest("Cannot load 'vivid' module") + # wait for device to appear, or a timeout + self.wait_for_webcam(self.source) + + p = self.loop.run_until_complete(asyncio.create_subprocess_exec( + "qvm-device", "webcam", "attach", self.view.name, + f"{self.source.name}:dev-video0", + "-o", "format=640x480x30", + )) + self.loop.run_until_complete(p.wait()) + self.wait_for_video0(self.view) + + destination_image = self.capture_from_video(self.view) + destination_image = self.apply_mask(destination_image) + p = self.loop.run_until_complete(asyncio.create_subprocess_exec( + "qvm-device", "webcam", "detach", self.view.name, + f"{self.source.name}:dev-video0" + )) + self.loop.run_until_complete(p.wait()) + self.wait_for_video0_disconnect(self.view) + + # vivid supports only one client at a time, so capture source only + # after QVC disconnects + 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) + def list_tests(): return (