├── .gitignore ├── .gitlab-ci.yml ├── .qubesbuilder ├── Makefile ├── Makefile.builder ├── README.md ├── archlinux └── PKGBUILD.in ├── debian ├── changelog ├── compat ├── control ├── copyright ├── docs ├── rules └── source │ └── format ├── qubes-rpc ├── qubes.USB ├── qubes.USB.policy ├── qubes.USBAttach └── qubes.USBDetach ├── qubesusbproxy ├── __init__.py ├── core3ext.py ├── tests.py └── utils.py ├── rpm_spec ├── qubes-usb-proxy-dom0.spec.in └── qubes-usb-proxy.spec.in ├── setup.py ├── src ├── 80-qubes-usb-reset.rules ├── usb-detach-all ├── usb-export ├── usb-import └── usb-reset └── version /.gitignore: -------------------------------------------------------------------------------- 1 | pkgs/ 2 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | include: 2 | - file: /r4.3/gitlab-base.yml 3 | project: QubesOS/qubes-continuous-integration 4 | - file: /r4.3/gitlab-host.yml 5 | project: QubesOS/qubes-continuous-integration 6 | - file: /r4.3/gitlab-vm.yml 7 | project: QubesOS/qubes-continuous-integration 8 | - file: /r4.3/gitlab-host-vm-openqa.yml 9 | project: QubesOS/qubes-continuous-integration 10 | 11 | mypy: 12 | stage: checks 13 | image: fedora:40 14 | tags: 15 | - docker 16 | before_script: 17 | - sudo dnf install -y python3-mypy python3-pip 18 | script: 19 | - mypy --install-types --non-interactive --ignore-missing-imports --junit-xml mypy.xml qubesusbproxy 20 | artifacts: 21 | reports: 22 | junit: mypy.xml 23 | -------------------------------------------------------------------------------- /.qubesbuilder: -------------------------------------------------------------------------------- 1 | host: 2 | rpm: 3 | build: 4 | - rpm_spec/qubes-usb-proxy-dom0.spec 5 | vm: 6 | rpm: 7 | build: 8 | - rpm_spec/qubes-usb-proxy.spec 9 | deb: 10 | build: 11 | - debian 12 | archlinux: 13 | build: 14 | - archlinux 15 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | install-vm: 2 | install -d $(DESTDIR)/etc/qubes-rpc 3 | install qubes-rpc/qubes.USBAttach $(DESTDIR)/etc/qubes-rpc 4 | install qubes-rpc/qubes.USBDetach $(DESTDIR)/etc/qubes-rpc 5 | install qubes-rpc/qubes.USB $(DESTDIR)/etc/qubes-rpc 6 | install -d $(DESTDIR)/usr/lib/qubes 7 | install src/usb-* $(DESTDIR)/usr/lib/qubes 8 | install -d $(DESTDIR)/usr/lib/udev/rules.d 9 | install src/*.rules $(DESTDIR)/usr/lib/udev/rules.d 10 | install -d $(DESTDIR)/etc/qubes/suspend-pre.d 11 | ln -s ../../../usr/lib/qubes/usb-detach-all \ 12 | $(DESTDIR)/etc/qubes/suspend-pre.d/usb-detach-all.sh 13 | 14 | install-dom0: 15 | python3 setup.py install -O1 --root $(DESTDIR) 16 | install -D -m 0664 qubes-rpc/qubes.USB.policy $(DESTDIR)/etc/qubes-rpc/policy/qubes.USB 17 | 18 | -------------------------------------------------------------------------------- /Makefile.builder: -------------------------------------------------------------------------------- 1 | RPM_SPEC_FILES.vm := rpm_spec/qubes-usb-proxy.spec 2 | RPM_SPEC_FILES.dom0 := rpm_spec/qubes-usb-proxy-dom0.spec 3 | RPM_SPEC_FILES = $(RPM_SPEC_FILES.$(PACKAGE_SET)) 4 | DEBIAN_BUILD_DIRS := debian 5 | ARCH_BUILD_DIRS := archlinux 6 | 7 | # Support for new packaging 8 | ifneq ($(filter $(DISTRIBUTION), archlinux),) 9 | VERSION := $(file <$(ORIG_SRC)/$(DIST_SRC)/version) 10 | GIT_TARBALL_NAME ?= qubes-usb-proxy-$(VERSION)-1.tar.gz 11 | SOURCE_COPY_IN := source-archlinux-copy-in 12 | 13 | source-archlinux-copy-in: PKGBUILD = $(CHROOT_DIR)/$(DIST_SRC)/$(ARCH_BUILD_DIRS)/PKGBUILD 14 | source-archlinux-copy-in: 15 | cp $(PKGBUILD).in $(CHROOT_DIR)/$(DIST_SRC)/PKGBUILD 16 | sed -i "s/@VERSION@/$(VERSION)/g" $(CHROOT_DIR)/$(DIST_SRC)/PKGBUILD 17 | sed -i "s/@REL@/1/g" $(CHROOT_DIR)/$(DIST_SRC)/PKGBUILD 18 | endif 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | USB proxy based on USBIP and qrexec 2 | =================================== 3 | 4 | USB device passthrough using USBIP as a protocol, but qrexec as link layer. 5 | See https://github.com/QubesOS/qubes-issues/issues/531 for more details. 6 | 7 | Installation 8 | ------------ 9 | 10 | If you want to install `qubes-app-linux-usb-proxy` not for development but for usage, 11 | please refer to the [USB Documentation of Qubes][documentation-usb] 12 | to install it securely with your package manager. 13 | 14 | Technical details of USBIP 15 | -------------------------- 16 | 17 | USBIP consists of two parts: 18 | 19 | - frontend module (vhci-hcd) - virtual USB controller 20 | - backend module (usbip-host) - USB device driver 21 | 22 | Normally (with TCP as link layer), configuration is handled by `usbipd` and 23 | `usbip` tools. Those tools setup TCP connection, then pass socket FD to the 24 | kernel. 25 | For frontend it is done by writing port number, transport socket FD, busid and 26 | speed to `/sys/devices/platform/vhci_hcd/attach`. For backend - by attaching 27 | driver to appropriate device, then writing socket FD to 28 | `/sys/bus/usb/devices/.../usbip_sockfd`. 29 | 30 | In case of qrexec, it can also provide a single (local) socket, which can be 31 | used for that purpose. One need to send `SIGUSR1` to `$QREXEC_AGENT_PID` to 32 | switch to that mode (other wise separate sockets are used for data IN (stdin) 33 | and OUT (stdout)) - then stdin (FD 0) can be used for both directions. 34 | 35 | Some more info is needed at frontend side (vhci-hcd): 36 | 37 | - port number - can be obtained by parsing 38 | `/sys/devices/platform/vhci_hcd/status` (status codes are defined in 39 | `/usr/include/linux/usbip.h`) 40 | - remote devid (bus and dev number) and device speed - can be obtained by the 41 | backend and transfered to the frontend script 42 | 43 | 44 | Low level description 45 | --------------------- 46 | 47 | Internally three qrexec services are used: 48 | 49 | 1. `qubes.USBAttach` - called by dom0 in frontend domain to initiate 50 | connection. Requires backend domain name and device busid on its stdin 51 | (separated by space). Service will terminate as soon as connection is 52 | established. 53 | 2. `qubes.USBDetach` - similar to `qubes.USBAttach` but to terminate the 54 | connection. Parameters on stdin are the same. 55 | 3. `qubes.USB` - actual USBIP connection, called by frontend domain to the 56 | backend domain, with desired busid as 57 | [service argument](https://github.com/QubesOS/qubes-issues/issues/1876). 58 | 59 | 60 | `qubes.USBAttach` service calls `qubes.USB` in the backend, using `usb-import` 61 | script as local process. `usb-import` script is responsible for configuring vhci-hcd, 62 | which includes: 63 | 64 | - finding free port 65 | - retrieving (and validating) devid and speed from the backend 66 | - actually attaching the device using FD number 0 (stdin) 67 | 68 | It also save state information (port number) for later use by `qubes.USBDetach`. 69 | 70 | `qubes.USB` service calls `usb-export` script, which resolve/validate given 71 | device, bind it to the usbip-host driver and send devid and speed to the 72 | frontend (in a single line, space delimited). After that, it hands over stdin 73 | socket (FD 0) to the kernel for USBIP communication. 74 | 75 | Supported argument formats: 76 | - `VENDORID.PRODUCTID`, where each of them is in 0xHHHH format (four hex digits) 77 | - `BUSNUM-DEVNUM.PORT`, device name as in `/sys/bus/usb/devices` (important: 78 | whole device, not a signle interface!) 79 | 80 | Low level usage 81 | --------------- 82 | 83 | First you need to setup qrexec policy to access `qubes.USB+DEVID` calls from 84 | frontend to backend. Then you need to call `qubes.USBAttach` service. Examples 85 | below. 86 | 87 | Attach device `2-1` of domain `sys-usb` to domain `work-usb`: 88 | 89 | echo sys-usb 2-1 | qvm-run -p -u root work-usb 'QUBESRPC qubes.USBAttach dom0' 90 | 91 | Detach that device: 92 | 93 | echo sys-usb 2-1 | qvm-run -p -u root work-usb 'QUBESRPC qubes.USBDetach dom0' 94 | 95 | Alternativelly you can detach the device calling backend domain (USB VM): 96 | 97 | echo 2-1 | qvm-run -p -u root sys-usb 'QUBESRPC qubes.USBDetach dom0' 98 | 99 | Using python API it will be: 100 | 101 | frontend_vm.run_service('qubes.USBAttach', input='sys-usb 2-1', user='root') 102 | frontend_vm.run_service('qubes.USBDetach', input='sys-usb 2-1', user='root') 103 | 104 | backend_vm.run_service('qubes.USBDetach', input='2-1', user='root') 105 | 106 | [documentation-usb]: https://www.qubes-os.org/doc/usb/ 107 | -------------------------------------------------------------------------------- /archlinux/PKGBUILD.in: -------------------------------------------------------------------------------- 1 | # Maintainer: Frédéric Pierret (fepitre) 2 | 3 | pkgname=qubes-usb-proxy 4 | pkgver=@VERSION@ 5 | pkgrel=@REL@ 6 | pkgdesc="The Qubes service for proxying USB devices" 7 | arch=("x86_64") 8 | url="http://qubes-os.org/" 9 | license=('GPL') 10 | depends=(sh usbutils qubes-vm-core) 11 | makedepends=(pkg-config make gcc) 12 | _pkgnvr="${pkgname}-${pkgver}-${pkgrel}" 13 | source=("${_pkgnvr}.tar.gz") 14 | sha256sums=(SKIP) 15 | 16 | package() { 17 | cd "${_pkgnvr}" 18 | make install-vm \ 19 | DESTDIR="${pkgdir}" \ 20 | LIBDIR=/usr/lib \ 21 | USRLIBDIR=/usr/lib \ 22 | SYSLIBDIR=/usr/lib 23 | } 24 | -------------------------------------------------------------------------------- /debian/changelog: -------------------------------------------------------------------------------- 1 | qubes-usb-proxy (4.3.0) unstable; urgency=medium 2 | 3 | [ Marek Marczykowski-Górecki ] 4 | * ci: drop R4.1, add R4.3, add openqa build 5 | 6 | [ Piotr Bartman-Szwarc ] 7 | * q-dev: port 8 | * q-dev: check identity 9 | * q-dev: refactor device_protocol.py 10 | * q-dev: implements device_id 11 | * q-dev: fix detaching 12 | * q-dev: update device utils 13 | * q-dev: assignment.devices 14 | * q-dev: backward compatibility 15 | * q-dev: fix detaching 16 | * q-dev: auto-attaching order 17 | * q-dev: fix auto-attach 18 | * q-dev: add more tests 19 | * q-dev: update utils 20 | * q-dev: correct english grammar 21 | * q-dev: async confirmation 22 | * q-dev: update utils.py 23 | * q-dev: forbid usb assignment options 24 | * q-dev: updated tests and wait for attachment to be done 25 | * q-dev: rename attach-confirm -> qubes-device-attach-confirm 26 | * q-dev: update tests 27 | * typo 28 | * q-dev: cleanup 29 | * q-dev: call attach-confirm socket directly 30 | * q-dev: update common part with admin-core 31 | 32 | [ Frédéric Pierret (fepitre) ] 33 | * Use Port for device and make mypy happy 34 | * Remove core2 tests 35 | * tests: use AsyncMock 36 | 37 | [ Piotr Bartman-Szwarc ] 38 | * q-dev: replace overriding with patching 39 | 40 | [ Marek Marczykowski-Górecki ] 41 | * Require new core-admin 42 | * ci: drop R4.2 43 | 44 | -- Marek Marczykowski-Górecki Sun, 17 Nov 2024 03:48:31 +0100 45 | 46 | qubes-usb-proxy (1.3.2) unstable; urgency=medium 47 | 48 | [ Ali Mirjamali ] 49 | * Replace line-feed with comma plus space in message 50 | 51 | -- Marek Marczykowski-Górecki Sat, 28 Sep 2024 15:49:42 +0200 52 | 53 | qubes-usb-proxy (1.3.1) unstable; urgency=medium 54 | 55 | * tests: assertEquals -> assertEqual 56 | 57 | -- Marek Marczykowski-Górecki Sat, 24 Aug 2024 02:42:07 +0200 58 | 59 | qubes-usb-proxy (1.3.0) unstable; urgency=medium 60 | 61 | [ Piotr Bartman ] 62 | * q-dev: implement part of new API for DeviceInfo 63 | * q-dev: events 64 | * q-dev: DeviceInterface 65 | * q-dev: assignments -> get_assigned_devices 66 | * q-dev: fire device-attach on domain start 67 | * q-dev: usb device full identity 68 | * q-dev: frontend_device -> attachment 69 | * q-dev: implementation of self_identity 70 | * q-dev: port assignment 71 | * q-dev: use ext/utils 72 | * q-dev: device protocol 73 | * q-dev: minor optimization 74 | 75 | [ Piotr Bartman-Szwarc ] 76 | * q-dev: update integ tests 77 | * q-dev: fix attaching usb devices on domain start 78 | * q-dev: small fix for unknown devices 79 | * q-dev: keep partial backward compatibility 80 | * q-dev: keep partial backward compatibility for auto-attachment 81 | * q-dev: keep partial backward compatibility in tests 82 | * q-dev: do not use unicode_escape 83 | * q-dev: cleanup 84 | * q-dev: handle invalid values 85 | * q-dev: handle invalid values 86 | * q-dev: fix loop 87 | 88 | -- Marek Marczykowski-Górecki Fri, 21 Jun 2024 15:29:50 +0200 89 | 90 | qubes-usb-proxy (1.2.2) unstable; urgency=medium 91 | 92 | * Do not needlessly reattach device to usbip-host 93 | * Add mechanism to reset device just before attaching 94 | * Enable reset on attach for Nitrokey 3 Bootloader 95 | 96 | -- Marek Marczykowski-Górecki Wed, 29 May 2024 11:48:04 +0200 97 | 98 | qubes-usb-proxy (1.2.1) unstable; urgency=medium 99 | 100 | * Remove too strict validation of a trusted parameter 101 | 102 | -- Marek Marczykowski-Górecki Mon, 15 Apr 2024 03:27:55 +0200 103 | 104 | qubes-usb-proxy (1.2.0) unstable; urgency=medium 105 | 106 | [ Marek Marczykowski-Górecki ] 107 | * tests: add trailing newline to qubes.USBAttach input 108 | 109 | [ Demi Marie Obenour ] 110 | * Clean up various shell scripts 111 | * Support devices blocked by usbguard 112 | 113 | -- Marek Marczykowski-Górecki Wed, 27 Mar 2024 21:16:29 +0100 114 | 115 | qubes-usb-proxy (1.1.5) unstable; urgency=medium 116 | 117 | [ Frédéric Pierret (fepitre) ] 118 | * Rework Archlinux packaging 119 | * Bare support for new packaging with PKGBUILD.in 120 | 121 | -- Marek Marczykowski-Górecki Sat, 29 Apr 2023 03:28:05 +0200 122 | 123 | qubes-usb-proxy (1.1.4) unstable; urgency=medium 124 | 125 | * Add missing import collections 126 | 127 | -- Marek Marczykowski-Górecki Wed, 18 Jan 2023 22:01:31 +0100 128 | 129 | qubes-usb-proxy (1.1.3) unstable; urgency=medium 130 | 131 | [ Rudd-O ] 132 | * Fix sys-usb: keyerror that prevents persistent attachments on VM 133 | start. 134 | 135 | [ Frédéric Pierret (fepitre) ] 136 | * Cleanup python2 code and packaging 137 | * spec: add BR python3-setuptools 138 | 139 | -- Marek Marczykowski-Górecki Wed, 18 Jan 2023 13:50:25 +0100 140 | 141 | qubes-usb-proxy (1.1.2) unstable; urgency=medium 142 | 143 | [ Frédéric Pierret (fepitre) ] 144 | * Drop Travis CI 145 | * Add Qubes Builder v2 integration 146 | * .qubesbuilder: replace 'spec' by 'build' 147 | 148 | [ Marek Marczykowski-Górecki ] 149 | * Use async/await 150 | * Detach all devices before suspending 151 | 152 | -- Marek Marczykowski-Górecki Thu, 27 Oct 2022 02:50:39 +0200 153 | 154 | qubes-usb-proxy (1.1.1) unstable; urgency=medium 155 | 156 | * Fix checking if modprobe is available 157 | * tests: increase timeout for device reconnect 158 | 159 | -- Marek Marczykowski-Górecki Fri, 10 Sep 2021 13:11:39 +0200 160 | 161 | qubes-usb-proxy (1.1.0) unstable; urgency=medium 162 | 163 | [ Dmitry Fedorov ] 164 | * winusb: attach usb device to stubdom if feature enabled 165 | * winusb: test usb device in stubdom 166 | * winusb: Write guest domain to the qubesdb when device connected to 167 | stubdom 168 | * winusb: make modprobe and udevadm usage optional 169 | * winusb: fix feature naming 170 | * winusb: remove redundant rules 171 | * winusb: fix feature naming 172 | 173 | [ Marek Marczykowski-Górecki ] 174 | * Make it work on R4.0 too 175 | 176 | -- Marek Marczykowski-Górecki Sat, 10 Jul 2021 05:20:24 +0200 177 | 178 | qubes-usb-proxy (1.0.30) unstable; urgency=medium 179 | 180 | [ Frédéric Pierret (fepitre) ] 181 | * Add .gitlab-ci.yml 182 | * spec: add BR make 183 | 184 | -- Marek Marczykowski-Górecki Sun, 23 May 2021 17:28:58 +0200 185 | 186 | qubes-usb-proxy (1.0.29) wheezy; urgency=medium 187 | 188 | [ Frédéric Pierret (fepitre) ] 189 | * Update travis 190 | 191 | [ Marek Marczykowski-Górecki ] 192 | * Add Super Speed Plus (10000) to allowed speed values 193 | 194 | -- Marek Marczykowski-Górecki Sat, 15 Aug 2020 01:37:39 +0200 195 | 196 | qubes-usb-proxy (1.0.28) unstable; urgency=medium 197 | 198 | * tests: adjust delay after removing/detaching device 199 | * Keep the process running to not close qrexec connection prematurely 200 | 201 | -- Marek Marczykowski-Górecki Wed, 01 Apr 2020 02:56:10 +0200 202 | 203 | qubes-usb-proxy (1.0.27) unstable; urgency=medium 204 | 205 | * Clear cache when Qubes() object is getting destroyed 206 | 207 | -- Marek Marczykowski-Górecki Fri, 17 Jan 2020 04:22:13 +0100 208 | 209 | qubes-usb-proxy (1.0.26) unstable; urgency=medium 210 | 211 | * Fix attach timeout handling 212 | 213 | -- Marek Marczykowski-Górecki Wed, 08 Jan 2020 00:07:23 +0100 214 | 215 | qubes-usb-proxy (1.0.25) unstable; urgency=medium 216 | 217 | * Add attach timeout 218 | * Don't include python2 tests on new dom0 (based on >f28) 219 | * travis: switch R4.1 to fc31 dom0 220 | 221 | -- Marek Marczykowski-Górecki Mon, 06 Jan 2020 03:33:59 +0100 222 | 223 | qubes-usb-proxy (1.0.24) unstable; urgency=medium 224 | 225 | * Fix device cache initialization 226 | 227 | -- Marek Marczykowski-Górecki Wed, 11 Dec 2019 06:00:23 +0100 228 | 229 | qubes-usb-proxy (1.0.23) unstable; urgency=medium 230 | 231 | [ Frédéric Pierret (fepitre) ] 232 | * travis: switch to bionic 233 | 234 | [ Marek Marczykowski-Górecki ] 235 | * Send events even when device is attached/detached by alternative 236 | method 237 | * Ensure dom0 receives events on attach/detach 238 | 239 | -- Marek Marczykowski-Górecki Tue, 10 Dec 2019 17:27:03 +0100 240 | 241 | qubes-usb-proxy (1.0.22) wheezy; urgency=medium 242 | 243 | * Fix granting/revoking qubes.USB service access 244 | * travis: switch to xenial, drop R3.2, update distros 245 | 246 | -- Marek Marczykowski-Górecki Thu, 10 Oct 2019 14:51:43 +0200 247 | 248 | qubes-usb-proxy (1.0.21) unstable; urgency=medium 249 | 250 | [ Malte Leip ] 251 | * usb-export: always unbind from current driver 252 | 253 | [ Nicco Kunzmann ] 254 | * Add error message with source 255 | * Clarify how to install this package securely 256 | 257 | -- Marek Marczykowski-Górecki Thu, 16 May 2019 20:12:29 +0200 258 | 259 | qubes-usb-proxy (1.0.20) unstable; urgency=medium 260 | 261 | [ Nicco Kunzmann ] 262 | * Clarify how to install this package sucurely 263 | * Add error message with source 264 | 265 | [ Marek Marczykowski-Górecki ] 266 | * Automatically re-connect persistent device when it gets plugged back 267 | * tests: verify automatic re-connection of the device 268 | * rpm: fix dom0 spec file 269 | * travis: add R4.1 270 | 271 | -- Marek Marczykowski-Górecki Fri, 08 Mar 2019 03:01:30 +0100 272 | 273 | qubes-usb-proxy (1.0.19) unstable; urgency=medium 274 | 275 | * tests: skip test for whonix-gw 276 | * rpm: fix build dependencies for dom0 package 277 | * travis: update Fedora and Debian versions 278 | 279 | -- Marek Marczykowski-Górecki Tue, 09 Oct 2018 04:44:11 +0200 280 | 281 | qubes-usb-proxy (1.0.18) unstable; urgency=medium 282 | 283 | [ Marek Marczykowski-Górecki ] 284 | * tests: mark attach_not_installed_back with expectedFailure 285 | 286 | [ Frédéric Pierret ] 287 | * Create .spec.in and Source0 288 | * spec.in: add changelog placeholder 289 | 290 | -- Marek Marczykowski-Górecki Mon, 07 May 2018 19:53:03 +0200 291 | 292 | qubes-usb-proxy (1.0.17) unstable; urgency=medium 293 | 294 | * Restore workaround for typo in usbip port status header 295 | 296 | -- Marek Marczykowski-Górecki Wed, 21 Mar 2018 02:40:53 +0100 297 | 298 | qubes-usb-proxy (1.0.16) unstable; urgency=medium 299 | 300 | * core3ext: do not include dom0 devices by default 301 | * core3ext: attach devices in -pre event 302 | * core3ext: send device-list-change event on backend domain shutdown 303 | 304 | -- Marek Marczykowski-Górecki Tue, 13 Feb 2018 05:17:46 +0100 305 | 306 | qubes-usb-proxy (1.0.15) unstable; urgency=medium 307 | 308 | * Depends on usbutils 309 | 310 | -- Marek Marczykowski-Górecki Mon, 22 Jan 2018 21:31:56 +0100 311 | 312 | qubes-usb-proxy (1.0.14) unstable; urgency=medium 313 | 314 | * Fix handling SuperSpeed port numbers 315 | 316 | -- Marek Marczykowski-Górecki Fri, 19 Jan 2018 04:36:01 +0100 317 | 318 | qubes-usb-proxy (1.0.13) unstable; urgency=medium 319 | 320 | * Fix device path for kernel >= 4.13 321 | * Add support for USB3 322 | * tests: mark attach_not_installed_back with expectedFailure 323 | * tests: install python modules for both python2 and python3 324 | * tests: load dummy-hcd module 325 | * tests: update core3 tests for the final API and python3 326 | 327 | -- Marek Marczykowski-Górecki Thu, 18 Jan 2018 17:37:01 +0100 328 | 329 | qubes-usb-proxy (1.0.12) unstable; urgency=medium 330 | 331 | [ Nedyalko Andreev ] 332 | * Add a simple PKGBUILD file for archlinux builds 333 | 334 | [ Marek Marczykowski-Górecki ] 335 | * Fix VM startup with USB device assigned (core3) 336 | * core3: do not fail listing on devices without description 337 | * Improve error message when backend fails to send device info 338 | 339 | -- Marek Marczykowski-Górecki Tue, 21 Nov 2017 05:07:11 +0100 340 | 341 | qubes-usb-proxy (1.0.11) unstable; urgency=medium 342 | 343 | * core3: ignore non-ascii characters in device description 344 | 345 | -- Marek Marczykowski-Górecki Fri, 11 Aug 2017 13:35:02 +0200 346 | 347 | qubes-usb-proxy (1.0.10) unstable; urgency=medium 348 | 349 | * core3: fix qdb->untrusted_qdb in one more place 350 | * core3: hide device hardware id ni description 351 | 352 | -- Marek Marczykowski-Górecki Sat, 29 Jul 2017 14:27:49 +0200 353 | 354 | qubes-usb-proxy (1.0.9) unstable; urgency=medium 355 | 356 | * Follow vm.qdb -> vm.untrusted_qdb rename 357 | * core3: follow change of qdb.list return type 358 | * core3: translate QubesDB changes into device-list-change event 359 | 360 | -- Marek Marczykowski-Górecki Sat, 29 Jul 2017 05:35:00 +0200 361 | 362 | qubes-usb-proxy (1.0.8) unstable; urgency=medium 363 | 364 | * Do not use sudo if already running as root 365 | * Migrate core3 extension to Python3, adjust to updated API (part 2) 366 | 367 | -- Marek Marczykowski-Górecki Wed, 05 Jul 2017 02:45:50 +0200 368 | 369 | qubes-usb-proxy (1.0.7) unstable; urgency=medium 370 | 371 | * Adjust to updated API (part 2) 372 | 373 | -- Marek Marczykowski-Górecki Mon, 26 Jun 2017 12:45:30 +0200 374 | 375 | qubes-usb-proxy (1.0.6) unstable; urgency=medium 376 | 377 | * Add core3 integration 378 | * tests: core3 integration 379 | * rpm: include egg-info directory 380 | * Add required lxml to setup.py 381 | * travis: drop debootstrap workaround 382 | * core3: allow devices connected to hubs 383 | * travis: add Qubes 4.0 builds 384 | * Migrate core3 extension to Python3, adjust to updated API (part 1) 385 | * rpm: fix build dependencies (migration to python3) 386 | 387 | -- Marek Marczykowski-Górecki Sat, 24 Jun 2017 10:34:35 +0200 388 | 389 | qubes-usb-proxy (1.0.5) wheezy; urgency=medium 390 | 391 | * Fix bash-ism one more time 392 | 393 | -- Marek Marczykowski-Górecki Tue, 19 Jul 2016 01:44:11 +0200 394 | 395 | qubes-usb-proxy (1.0.4) wheezy; urgency=medium 396 | 397 | * Prefer reporting error code from backend domain 398 | * tests: handle package not installed errors 399 | * rpm: use `version` file for dom0 package too 400 | 401 | -- Marek Marczykowski-Górecki Tue, 12 Jul 2016 06:23:29 +0200 402 | 403 | qubes-usb-proxy (1.0.3) wheezy; urgency=medium 404 | 405 | * Fix detecting disconnected device 406 | * Fix announcing where device is connected 407 | 408 | -- Marek Marczykowski-Górecki Sun, 03 Jul 2016 23:30:09 +0200 409 | 410 | qubes-usb-proxy (1.0.2) wheezy; urgency=medium 411 | 412 | * One more bash-related fix 413 | * tests: increase wait time for device disconnection 414 | 415 | -- Marek Marczykowski-Górecki Fri, 24 Jun 2016 22:44:26 +0200 416 | 417 | qubes-usb-proxy (1.0.1) wheezy; urgency=medium 418 | 419 | * Don't use bash-specific features 420 | * rpm: use `version` file for package version 421 | 422 | -- Marek Marczykowski-Górecki Sat, 18 Jun 2016 02:10:56 +0200 423 | 424 | qubes-usb-proxy (1.0.0) wheezy; urgency=medium 425 | 426 | * 427 | 428 | -- Marek Marczykowski-Górecki Thu, 02 Jun 2016 04:26:51 +0200 429 | 430 | qubes-usb-proxy (0.1) unstable; urgency=low 431 | 432 | * Initial release for debian 433 | 434 | -- Marek Marczykowski-Górecki Thu, 02 Jun 2016 04:01:36 +0200 435 | -------------------------------------------------------------------------------- /debian/compat: -------------------------------------------------------------------------------- 1 | 9 2 | -------------------------------------------------------------------------------- /debian/control: -------------------------------------------------------------------------------- 1 | Source: qubes-usb-proxy 2 | Section: utils 3 | Priority: optional 4 | Maintainer: Marek Marczykowski-Górecki 5 | Build-Depends: debhelper (>= 9) 6 | Standards-Version: 3.9.6 7 | Homepage: http://www.qubes-os.org 8 | 9 | Package: qubes-usb-proxy 10 | Architecture: any 11 | Depends: ${shlibs:Depends}, qubes-core-agent(>=3.2.0), usbutils 12 | Description: USBIP wrapper to run it over Qubes RPC connection 13 | This package wraps USBIP connection in Qubes RPC, which makes it possible to 14 | passthrough USB device between qubes, without enabling networking between 15 | them. 16 | -------------------------------------------------------------------------------- /debian/copyright: -------------------------------------------------------------------------------- 1 | Format: http://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ 2 | Upstream-Name: qubes-usb-proxy 3 | Source: 4 | 5 | Files: * 6 | Copyright: 2016 Qubes Developers 7 | License: GPL-2+ 8 | This package is free software; you can redistribute it and/or modify 9 | it under the terms of the GNU General Public License as published by 10 | the Free Software Foundation; either version 2 of the License, or 11 | (at your option) any later version. 12 | . 13 | This package is distributed in the hope that it will be useful, 14 | but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | GNU General Public License for more details. 17 | . 18 | You should have received a copy of the GNU General Public License 19 | along with this program. If not, see 20 | . 21 | On Debian systems, the complete text of the GNU General 22 | Public License version 2 can be found in "/usr/share/common-licenses/GPL-2". 23 | 24 | Files: debian/* 25 | Copyright: 2016 Marek Marczykowski-Górecki 26 | License: GPL-2+ 27 | This package is free software; you can redistribute it and/or modify 28 | it under the terms of the GNU General Public License as published by 29 | the Free Software Foundation; either version 2 of the License, or 30 | (at your option) any later version. 31 | . 32 | This package is distributed in the hope that it will be useful, 33 | but WITHOUT ANY WARRANTY; without even the implied warranty of 34 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 35 | GNU General Public License for more details. 36 | . 37 | You should have received a copy of the GNU General Public License 38 | along with this program. If not, see 39 | . 40 | On Debian systems, the complete text of the GNU General 41 | Public License version 2 can be found in "/usr/share/common-licenses/GPL-2". 42 | -------------------------------------------------------------------------------- /debian/docs: -------------------------------------------------------------------------------- 1 | README.md 2 | -------------------------------------------------------------------------------- /debian/rules: -------------------------------------------------------------------------------- 1 | #!/usr/bin/make -f 2 | # See debhelper(7) (uncomment to enable) 3 | # output every command that modifies files on the build system. 4 | #DH_VERBOSE = 1 5 | 6 | # see EXAMPLES in dpkg-buildflags(1) and read /usr/share/dpkg/* 7 | DPKG_EXPORT_BUILDFLAGS = 1 8 | include /usr/share/dpkg/default.mk 9 | 10 | export DESTDIR=$(shell readlink -m .)/debian/qubes-usb-proxy 11 | 12 | # see FEATURE AREAS in dpkg-buildflags(1) 13 | #export DEB_BUILD_MAINT_OPTIONS = hardening=+all 14 | 15 | # see ENVIRONMENT in dpkg-buildflags(1) 16 | # package maintainers to append CFLAGS 17 | #export DEB_CFLAGS_MAINT_APPEND = -Wall -pedantic 18 | # package maintainers to append LDFLAGS 19 | #export DEB_LDFLAGS_MAINT_APPEND = -Wl,--as-needed 20 | 21 | 22 | # main packaging script based on dh7 syntax 23 | %: 24 | dh $@ 25 | 26 | # debmake generated override targets 27 | # This is example for Cmake (See http://bugs.debian.org/641051 ) 28 | #override_dh_auto_configure: 29 | # dh_auto_configure -- \ 30 | # -DCMAKE_LIBRARY_PATH=$(DEB_HOST_MULTIARCH) 31 | 32 | override_dh_auto_install: 33 | make install-vm 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /debian/source/format: -------------------------------------------------------------------------------- 1 | 3.0 (native) 2 | -------------------------------------------------------------------------------- /qubes-rpc/qubes.USB: -------------------------------------------------------------------------------- 1 | #!/bin/sh -- 2 | set -eu 3 | 4 | service_arg="$1" 5 | if [ -z "$service_arg" ]; then 6 | echo "You must give device id as a service argument" >&2 7 | exit 1 8 | fi 9 | 10 | uid=$(id -u) 11 | if [ "$uid" -eq 0 ]; then 12 | exec /usr/lib/qubes/usb-export "$service_arg" 13 | else 14 | # preserve QREXEC_AGENT_PID variable 15 | exec sudo -E /usr/lib/qubes/usb-export "$service_arg" 16 | fi 17 | -------------------------------------------------------------------------------- /qubes-rpc/qubes.USB.policy: -------------------------------------------------------------------------------- 1 | $anyvm $anyvm deny 2 | -------------------------------------------------------------------------------- /qubes-rpc/qubes.USBAttach: -------------------------------------------------------------------------------- 1 | #!/bin/sh -- 2 | set -eu 3 | 4 | read domain busid 5 | statefile="/var/run/qubes/usb-import-${domain}-${busid}.state" 6 | export "SERVICE_ATTACH_PID=$$" 7 | trap "exit 0" HUP 8 | # don't let qrexec-client-vm keeping open FDs - that would prevent 9 | # qubes.USBAttach service to end. 10 | # On the other hand, access stderr from inside of usb-import, to report 11 | # connection errors 12 | qrexec-client-vm "$domain" "qubes.USB+$busid" \ 13 | /usr/lib/qubes/usb-import "$statefile" \ 14 | /dev/null 2>/dev/null & 15 | # prefer reporting remote error code 16 | if wait "$!"; then 17 | exit 1 18 | else 19 | exit "$?" 20 | fi 21 | -------------------------------------------------------------------------------- /qubes-rpc/qubes.USBDetach: -------------------------------------------------------------------------------- 1 | #!/bin/sh -- 2 | set -eu 3 | 4 | read domain busid 5 | if [ -z "$busid" ]; then 6 | # when only one argument given, detach local device from whateved domain it 7 | # was exported to 8 | busid="$domain" 9 | usbip_sockfd="/sys/bus/usb/devices/$busid/usbip_sockfd" 10 | if [ -w "$usbip_sockfd" ]; then 11 | echo -1 > "$usbip_sockfd" 12 | else 13 | echo "Device $busid not found or not attached to any VM!" >&2 14 | exit 1 15 | fi 16 | else 17 | DEVPATH="/sys/devices/platform/vhci_hcd" 18 | if [ -d "${DEVPATH}.0" ]; then 19 | DEVPATH="${DEVPATH}.0" 20 | fi 21 | 22 | statefile="/var/run/qubes/usb-import-${domain}-${busid}.state" 23 | if [ ! -r "$statefile" ]; then 24 | echo "Device $busid from domain $domain not attached!" >&2 25 | exit 1 26 | fi 27 | read -r port < "$statefile" 28 | if ! port_status=$(grep -- "^\\(hs\\|ss\\)\\? *$port" "$DEVPATH/status"); then 29 | status=$? 30 | echo "Failed to find USB port '$port'" >&2 31 | exit "$status" 32 | fi 33 | local_busid=${port_status##* } 34 | echo "$port" > "$DEVPATH/detach" 35 | rm -f -- "$statefile" 36 | while [ -e "/sys/bus/usb/devices/$local_busid" ]; do 37 | sleep 0.2 38 | done 39 | fi 40 | -------------------------------------------------------------------------------- /qubesusbproxy/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QubesOS/qubes-app-linux-usb-proxy/74b21942c2edcd64ca0c78b457f213b634ef29ad/qubesusbproxy/__init__.py -------------------------------------------------------------------------------- /qubesusbproxy/core3ext.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 -O 2 | # vim: fileencoding=utf-8 3 | # 4 | # The Qubes OS Project, https://www.qubes-os.org/ 5 | # 6 | # Copyright (C) 2016 Marek Marczykowski-Górecki 7 | # 8 | # Copyright (C) 2024 Piotr Bartman-Szwarc 9 | # 10 | # 11 | # This program is free software; you can redistribute it and/or modify 12 | # it under the terms of the GNU General Public License as published by 13 | # the Free Software Foundation; either version 2 of the License, or 14 | # (at your option) any later version. 15 | # 16 | # This program is distributed in the hope that it will be useful, 17 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 18 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 19 | # GNU General Public License for more details. 20 | # 21 | # You should have received a copy of the GNU General Public License along 22 | # with this program; if not, write to the Free Software Foundation, Inc., 23 | # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 24 | import asyncio 25 | import collections 26 | import dataclasses 27 | import fcntl 28 | import grp 29 | import os 30 | import re 31 | import string 32 | import subprocess 33 | import sys 34 | 35 | import tempfile 36 | from typing import List, Optional, Dict, Tuple, Any 37 | 38 | from qubes.utils import sanitize_stderr_for_log 39 | 40 | try: 41 | from qubes.device_protocol import DeviceInfo 42 | from qubes.device_protocol import DeviceInterface 43 | from qubes.device_protocol import Port 44 | from qubes.ext import utils 45 | 46 | def get_assigned_devices(devices): 47 | yield from devices.get_assigned_devices() 48 | 49 | except ImportError: 50 | # This extension supports both the legacy and new device API. 51 | # In the case of the legacy backend, functionality is limited. 52 | from qubes.devices import DeviceInfo as LegacyDeviceInfo 53 | from qubesusbproxy import utils 54 | 55 | class DescriptionOverrider: 56 | # pylint: disable=too-few-public-methods 57 | @property 58 | def description(self): 59 | return self.name 60 | 61 | class DeviceInfo(DescriptionOverrider, LegacyDeviceInfo): # type: ignore 62 | def __init__(self, port): 63 | # not supported options in legacy code 64 | self.safe_chars = self.safe_chars.replace(" ", "") 65 | super().__init__(port.backend_domain, port.port_id) 66 | 67 | # needed but not in legacy DeviceInfo 68 | self._vendor = None 69 | self._product = None 70 | self._manufacturer = None 71 | self._name = None 72 | self._serial = None 73 | # `_load_interfaces_from_qubesdb` will never be called 74 | self._interfaces = "?******" 75 | 76 | @property 77 | def frontend_domain(self): 78 | return self.attachment 79 | 80 | @dataclasses.dataclass 81 | class Port: # type: ignore 82 | backend_domain: Any 83 | port_id: Any 84 | devclass: Any 85 | 86 | class DeviceInterface: # type: ignore 87 | # pylint: disable=too-few-public-methods 88 | pass 89 | 90 | def get_assigned_devices(devices): 91 | yield from devices.assignments(persistent=True) 92 | 93 | 94 | import qubes.devices 95 | import qubes.ext 96 | import qubes.vm.adminvm 97 | 98 | usb_device_re = re.compile(r"^[0-9]+-[0-9]+(_[0-9]+)*$") 99 | # should match valid VM name 100 | usb_connected_to_re = re.compile(rb"^[a-zA-Z][a-zA-Z0-9_.-]*$") 101 | usb_device_hw_ident_re = re.compile(r"^[0-9a-f]{4}:[0-9a-f]{4} ") 102 | 103 | HWDATA_PATH = "/usr/share/hwdata" 104 | 105 | 106 | class USBDevice(DeviceInfo): 107 | # pylint: disable=too-few-public-methods 108 | def __init__(self, port: qubes.device_protocol.Port): 109 | if port.devclass != "usb": 110 | raise qubes.exc.QubesValueError( 111 | f"Incompatible device class for input port: {port.devclass}" 112 | ) 113 | 114 | # the superclass can restrict the allowed characters 115 | self.safe_chars = ( 116 | string.ascii_letters + string.digits + string.punctuation + " " 117 | ) 118 | 119 | # init parent class 120 | super().__init__(port) 121 | 122 | self._qdb_ident = port.port_id.replace(".", "_") 123 | self._qdb_path = "/qubes-usb-devices/" + self._qdb_ident 124 | self._vendor_id: Optional[str] = None 125 | self._product_id: Optional[str] = None 126 | 127 | @property 128 | def vendor(self) -> str: 129 | """ 130 | Device vendor from local database `/usr/share/hwdata/usb.ids` 131 | 132 | Could be empty string or "unknown". 133 | 134 | Lazy loaded. 135 | """ 136 | if self._vendor is None: 137 | result = self._load_desc_from_qubesdb()["vendor"] 138 | else: 139 | result = self._vendor 140 | return result 141 | 142 | @property 143 | def product(self) -> str: 144 | """ 145 | Device name from local database `/usr/share/hwdata/usb.ids` 146 | 147 | Could be empty string or "unknown". 148 | 149 | Lazy loaded. 150 | """ 151 | if self._product is None: 152 | result = self._load_desc_from_qubesdb()["product"] 153 | else: 154 | result = self._product 155 | return result 156 | 157 | @property 158 | def manufacturer(self) -> str: 159 | """ 160 | The name of the manufacturer of the device introduced by device itself 161 | 162 | Could be empty string or "unknown". 163 | 164 | Lazy loaded. 165 | """ 166 | if self._manufacturer is None: 167 | result = self._load_desc_from_qubesdb()["manufacturer"] 168 | else: 169 | result = self._manufacturer 170 | return result 171 | 172 | @property 173 | def name(self) -> str: 174 | """ 175 | The name of the device it introduced itself with (could be empty string) 176 | 177 | Could be empty string or "unknown". 178 | 179 | Lazy loaded. 180 | """ 181 | if self._name is None: 182 | result = self._load_desc_from_qubesdb()["name"] 183 | else: 184 | result = self._name 185 | return result 186 | 187 | @property 188 | def serial(self) -> str: 189 | """ 190 | The serial number of the device it introduced itself with. 191 | 192 | Could be empty string or "unknown". 193 | 194 | Lazy loaded. 195 | """ 196 | if self._serial is None: 197 | result = self._load_desc_from_qubesdb()["serial"] 198 | else: 199 | result = self._serial 200 | return result 201 | 202 | @property 203 | def interfaces(self) -> List[DeviceInterface]: 204 | """ 205 | List of device interfaces. 206 | 207 | Every device should have at least one interface. 208 | """ 209 | if self._interfaces is None: 210 | result = self._load_interfaces_from_qubesdb() 211 | else: 212 | result = self._interfaces 213 | return result 214 | 215 | @property 216 | def parent_device(self) -> Optional[DeviceInfo]: 217 | """ 218 | The parent device, if any. 219 | 220 | A USB device has no parents. 221 | """ 222 | return None 223 | 224 | def _load_interfaces_from_qubesdb(self) -> List[DeviceInterface]: 225 | result = [DeviceInterface.unknown()] 226 | if not self.backend_domain.is_running(): 227 | # don't cache this value 228 | return result 229 | untrusted_interfaces: bytes = self.backend_domain.untrusted_qdb.read( 230 | self._qdb_path + "/interfaces" 231 | ) 232 | if not untrusted_interfaces: 233 | return result 234 | self._interfaces = result = [ 235 | DeviceInterface( 236 | self._sanitize(ifc, safe_chars=string.hexdigits), devclass="usb" 237 | ) 238 | for ifc in untrusted_interfaces.split(b":") 239 | if ifc 240 | ] 241 | return result 242 | 243 | def _load_desc_from_qubesdb(self) -> Dict[str, str]: 244 | unknown = "unknown" 245 | result = { 246 | "vendor": unknown, 247 | "vendor ID": "0000", 248 | "product": unknown, 249 | "product ID": "0000", 250 | "manufacturer": unknown, 251 | "name": unknown, 252 | "serial": unknown, 253 | } 254 | if not self.backend_domain.is_running(): 255 | # don't cache this value 256 | return result 257 | untrusted_device_desc: bytes = self.backend_domain.untrusted_qdb.read( 258 | self._qdb_path + "/desc" 259 | ) 260 | if not untrusted_device_desc: 261 | return result 262 | try: 263 | ( 264 | untrusted_vend_prod_id, 265 | untrusted_manufacturer, 266 | untrusted_name, 267 | untrusted_serial, 268 | ) = untrusted_device_desc.split(b" ") 269 | untrusted_vendor_id, untrusted_product_id = ( 270 | untrusted_vend_prod_id.split(b":") 271 | ) 272 | except ValueError: 273 | # desc doesn't contain correctly formatted data, 274 | # but it is not empty. We cannot parse it, 275 | # but we can still put it to the `name` just to provide 276 | # some information to the user. 277 | untrusted_vendor_id, untrusted_product_id = (b"0000", b"0000") 278 | (untrusted_manufacturer, untrusted_serial) = ( 279 | unknown.encode() for _ in range(2) 280 | ) 281 | untrusted_name = untrusted_device_desc.replace(b" ", b"_") 282 | 283 | # Data successfully loaded, cache these values 284 | self._vendor_id = result["vendor ID"] = self._sanitize( 285 | untrusted_vendor_id 286 | ) 287 | self._product_id = result["product ID"] = self._sanitize( 288 | untrusted_product_id 289 | ) 290 | vendor, product = self._get_vendor_and_product_names( 291 | self._vendor_id, self._product_id 292 | ) 293 | self._vendor = result["vendor"] = self._sanitize(vendor.encode()) 294 | self._product = result["product"] = self._sanitize(product.encode()) 295 | self._manufacturer = result["manufacturer"] = self._sanitize( 296 | untrusted_manufacturer 297 | ) 298 | self._name = result["name"] = self._sanitize(untrusted_name) 299 | self._name = result["serial"] = self._sanitize(untrusted_serial) 300 | return result 301 | 302 | def _sanitize( 303 | self, untrusted_device_desc: bytes, safe_chars: Optional[str] = None 304 | ) -> str: 305 | # rb'USB\x202.0\x20Camera' -> 'USB 2.0 Camera' 306 | if safe_chars is None: 307 | safe_chars = self.safe_chars 308 | safe_chars_set = set(safe_chars) 309 | 310 | result = "" 311 | i = 0 312 | while i < len(untrusted_device_desc): 313 | c = chr(untrusted_device_desc[i]) 314 | if c == "\\": 315 | i += 1 316 | if i >= len(untrusted_device_desc): 317 | break 318 | c = chr(untrusted_device_desc[i]) 319 | if c == "x": 320 | i += 2 321 | if i >= len(untrusted_device_desc): 322 | break 323 | hex_code = untrusted_device_desc[i - 1 : i + 1] 324 | try: 325 | for j in range(2): 326 | if hex_code[j] not in b"0123456789abcdefABCDEF": 327 | raise ValueError() 328 | hex_value = int(hex_code, 16) 329 | c = chr(hex_value) 330 | except ValueError: 331 | c = "_" 332 | 333 | if c in safe_chars_set: 334 | result += c 335 | else: 336 | result += "_" 337 | i += 1 338 | return result 339 | 340 | @property 341 | def attachment(self): 342 | if not self.backend_domain.is_running(): 343 | return None 344 | untrusted_connected_to = self.backend_domain.untrusted_qdb.read( 345 | self._qdb_path + "/connected-to" 346 | ) 347 | if not untrusted_connected_to: 348 | return None 349 | if not usb_connected_to_re.match(untrusted_connected_to): 350 | self.backend_domain.log.warning( 351 | f"Device {self.port_id} has invalid chars in connected-to " 352 | "property" 353 | ) 354 | return None 355 | untrusted_connected_to = untrusted_connected_to.decode( 356 | "ascii", errors="strict" 357 | ) 358 | try: 359 | connected_to = self.backend_domain.app.domains[ 360 | untrusted_connected_to 361 | ] 362 | except KeyError: 363 | self.backend_domain.log.warning( 364 | f"Device {self.port_id} has invalid VM name in connected-to " 365 | f"property: {untrusted_connected_to}" 366 | ) 367 | return None 368 | return connected_to 369 | 370 | @property 371 | def device_id(self) -> str: 372 | """ 373 | Get identification of a device not related to port. 374 | """ 375 | if self._vendor_id is None: 376 | vendor_id = self._load_desc_from_qubesdb()["vendor ID"] 377 | else: 378 | vendor_id = self._vendor_id 379 | if self._product_id is None: 380 | product_id = self._load_desc_from_qubesdb()["product ID"] 381 | else: 382 | product_id = self._product_id 383 | interfaces = "".join(repr(ifc) for ifc in self.interfaces) 384 | serial = self.serial if self.serial != "unknown" else "" 385 | return f"{vendor_id}:{product_id}:{serial}:{interfaces}" 386 | 387 | @staticmethod 388 | def _get_vendor_and_product_names( 389 | vendor_id: str, product_id: str 390 | ) -> Tuple[str, str]: 391 | """ 392 | Return tuple of vendor's and product's names for the ids. 393 | 394 | If the id is not known, return ("unknown", "unknown"). 395 | """ 396 | return ( 397 | USBDevice._load_usb_known_devices() 398 | .get(vendor_id, {}) 399 | .get(product_id, ("unknown", "unknown")) 400 | ) 401 | 402 | @staticmethod 403 | def _load_usb_known_devices() -> Dict[str, Dict[str, Tuple[str, str]]]: 404 | """ 405 | List of known device vendors, devices and interfaces. 406 | 407 | result[vendor_id][device_id] = (vendor_name, product_name) 408 | """ 409 | # Syntax: 410 | # vendor vendor_name <-- 2 spaces between 411 | # device device_name <-- single tab 412 | # interface interface_name <-- two tabs 413 | # ... 414 | # C class class_name 415 | # subclass subclass_name <-- single tab 416 | # prog-if prog-if_name <-- two tabs 417 | result: Dict[str, Dict] = {} 418 | with open( 419 | HWDATA_PATH + "/usb.ids", encoding="utf-8", errors="ignore" 420 | ) as usb_ids: 421 | vendor_id: Optional[str] = None 422 | vendor_name: Optional[str] = None 423 | for line in usb_ids.readlines(): 424 | line = line.rstrip() 425 | if line.startswith("#"): 426 | # skip comments 427 | continue 428 | if not line: 429 | # skip empty lines 430 | continue 431 | if line.startswith("\t\t"): 432 | # skip interfaces 433 | continue 434 | if line.startswith("C "): 435 | # description of classes starts here, we can finish 436 | break 437 | if line.startswith("\t"): 438 | # save vendor, device pair 439 | device_id, _, device_name = line[1:].split(" ", 2) 440 | if vendor_id is None or vendor_name is None: 441 | continue 442 | result[vendor_id][device_id] = vendor_name, device_name 443 | else: 444 | # new vendor 445 | vendor_id, _, vendor_name = line[:].split(" ", 2) 446 | result[vendor_id] = {} 447 | 448 | return result 449 | 450 | 451 | class USBProxyNotInstalled(qubes.exc.QubesException): 452 | pass 453 | 454 | 455 | class QubesUSBException(qubes.exc.QubesException): 456 | pass 457 | 458 | 459 | def modify_qrexec_policy(service, line, add): 460 | """ 461 | Add/remove *line* to qrexec policy of a *service*. 462 | If policy file is missing, it is created. If resulting policy would be 463 | empty, it is removed. 464 | 465 | :param service: service name 466 | :param line: line to add/remove 467 | :param add: True if line should be added, otherwise False 468 | :return: None 469 | """ 470 | path = f"/etc/qubes-rpc/policy/{service}" 471 | while True: 472 | with open(path, "a+") as policy: 473 | # take the lock here, it's released by closing the file 474 | fcntl.lockf(policy.fileno(), fcntl.LOCK_EX) 475 | # While we were waiting for lock, someone could have unlink()ed 476 | # (or rename()d) our file out of the filesystem. We have to 477 | # ensure we got lock on something linked to filesystem. 478 | # If not, try again. 479 | if os.fstat(policy.fileno()) != os.stat(path): 480 | continue 481 | 482 | policy.seek(0) 483 | 484 | policy_rules = policy.readlines() 485 | if add: 486 | policy_rules.insert(0, line) 487 | else: 488 | # handle also cases where previous cleanup failed or 489 | # was done manually 490 | while line in policy_rules: 491 | policy_rules.remove(line) 492 | 493 | if policy_rules: 494 | with tempfile.NamedTemporaryFile( 495 | prefix=path, delete=False 496 | ) as policy_new: 497 | policy_new.write("".join(policy_rules).encode()) 498 | policy_new.flush() 499 | try: 500 | os.chown( 501 | policy_new.name, -1, grp.getgrnam("qubes").gr_gid 502 | ) 503 | os.chmod(policy_new.name, 0o660) 504 | except KeyError: # group 'qubes' not found 505 | # don't change mode if no 'qubes' group in the system 506 | pass 507 | os.rename(policy_new.name, path) 508 | else: 509 | os.remove(path) 510 | break 511 | 512 | 513 | class USBDeviceExtension(qubes.ext.Extension): 514 | 515 | def __init__(self): 516 | super().__init__() 517 | # include dom0 devices in listing only when usb-proxy is really 518 | # installed there 519 | self.usb_proxy_installed_in_dom0 = os.path.exists( 520 | "/etc/qubes-rpc/qubes.USB" 521 | ) 522 | self.devices_cache = collections.defaultdict(dict) 523 | 524 | @qubes.ext.handler("domain-init", "domain-load") 525 | def on_domain_init_load(self, vm, event): 526 | """Initialize watching for changes""" 527 | # pylint: disable=unused-argument 528 | vm.watch_qdb_path("/qubes-usb-devices") 529 | if event == "domain-load": 530 | # avoid building a cache on domain-init, as it isn't fully set yet, 531 | # and definitely isn't running yet 532 | current_devices = { 533 | dev.port_id: dev.attachment 534 | for dev in self.on_device_list_usb(vm, None) 535 | } 536 | self.devices_cache[vm.name] = current_devices 537 | else: 538 | self.devices_cache[vm.name] = {} 539 | 540 | async def attach_and_notify(self, vm, assignment): 541 | # bypass DeviceCollection logic preventing double attach 542 | device = assignment.device 543 | if assignment.mode.value == "ask-to-attach": 544 | allowed = await utils.confirm_device_attachment( 545 | device, {vm: assignment} 546 | ) 547 | allowed = allowed.strip() 548 | if vm.name != allowed: 549 | return 550 | await self.on_device_attach_usb( 551 | vm, "device-pre-attach:usb", device, assignment.options 552 | ) 553 | await vm.fire_event_async( 554 | "device-attach:usb", device=device, options=assignment.options 555 | ) 556 | 557 | def ensure_detach(self, vm, port): 558 | """ 559 | Run this method if device is no longer detected. 560 | 561 | No additional action required in case of USB devices. 562 | """ 563 | pass 564 | 565 | @qubes.ext.handler("domain-qdb-change:/qubes-usb-devices") 566 | def on_qdb_change(self, vm, event, path): 567 | """A change in QubesDB means a change in a device list.""" 568 | # pylint: disable=unused-argument 569 | current_devices = dict( 570 | (dev.port_id, dev.attachment) 571 | for dev in self.on_device_list_usb(vm, None) 572 | ) 573 | utils.device_list_change(self, current_devices, vm, path, USBDevice) 574 | 575 | @qubes.ext.handler("device-list:usb") 576 | def on_device_list_usb(self, vm, event): 577 | # pylint: disable=unused-argument 578 | 579 | if not vm.is_running() or not hasattr(vm, "untrusted_qdb"): 580 | return 581 | 582 | if ( 583 | isinstance(vm, qubes.vm.adminvm.AdminVM) 584 | and not self.usb_proxy_installed_in_dom0 585 | ): 586 | return 587 | 588 | untrusted_dev_list = vm.untrusted_qdb.list("/qubes-usb-devices/") 589 | if not untrusted_dev_list: 590 | return 591 | # just get a list of devices, not its every property 592 | untrusted_dev_list = set( 593 | path.split("/")[2] for path in untrusted_dev_list 594 | ) 595 | for untrusted_qdb_ident in untrusted_dev_list: 596 | if not usb_device_re.match(untrusted_qdb_ident): 597 | vm.log.warning("Invalid USB device name detected") 598 | continue 599 | port_id = untrusted_qdb_ident.replace("_", ".") 600 | yield USBDevice(Port(vm, port_id, "usb")) 601 | 602 | @qubes.ext.handler("device-get:usb") 603 | def on_device_get_usb(self, vm, event, port_id): 604 | # pylint: disable=unused-argument 605 | if not vm.is_running(): 606 | return 607 | 608 | if vm.untrusted_qdb.list( 609 | "/qubes-usb-devices/" + port_id.replace(".", "_") 610 | ): 611 | yield USBDevice(Port(vm, port_id, "usb")) 612 | 613 | @staticmethod 614 | def get_all_devices(app): 615 | for vm in app.domains: 616 | if not vm.is_running() or not hasattr(vm, "devices"): 617 | continue 618 | 619 | for dev in vm.devices["usb"]: 620 | # there may be more than one USB-passthrough implementation 621 | if isinstance(dev, USBDevice): 622 | yield dev 623 | 624 | @qubes.ext.handler("device-list-attached:usb") 625 | def on_device_list_attached(self, vm, event, **kwargs): 626 | # pylint: disable=unused-argument 627 | if not vm.is_running(): 628 | return 629 | 630 | for dev in self.get_all_devices(vm.app): 631 | if dev.attachment == vm: 632 | yield (dev, {}) 633 | 634 | @qubes.ext.handler("device-pre-attach:usb") 635 | async def on_device_attach_usb(self, vm, event, device, options): 636 | # pylint: disable=unused-argument 637 | 638 | if options: 639 | raise qubes.exc.QubesException( 640 | "USB device attach does not support user options" 641 | ) 642 | 643 | if not vm.is_running() or vm.qid == 0: 644 | # print(f"Qube is not running, skipping attachment of {device}", 645 | # file=sys.stderr) 646 | return 647 | 648 | if not isinstance(device, USBDevice): 649 | # print("The device is not recognized as usb device, " 650 | # f"skipping attachment of {device}", 651 | # file=sys.stderr) 652 | return 653 | 654 | if device.attachment: 655 | raise qubes.devices.DeviceAlreadyAttached( 656 | f"Device {device} already attached to {device.attachment}" 657 | ) 658 | 659 | stubdom_qrexec = ( 660 | vm.virt_mode == "hvm" 661 | and vm.features.check_with_template("stubdom-qrexec", False) 662 | ) 663 | 664 | name = vm.name + "-dm" if stubdom_qrexec else vm.name 665 | 666 | extra_kwargs = {} 667 | if stubdom_qrexec: 668 | extra_kwargs["stubdom"] = True 669 | 670 | # update the cache before the call, to avoid sending duplicated events 671 | # (one on qubesdb watch and the other by the caller of this method) 672 | self.devices_cache[device.backend_domain.name][device.port_id] = vm 673 | 674 | # set qrexec policy to allow this device 675 | policy_line = f"{name} {device.backend_domain.name} allow,user=root\n" 676 | modify_qrexec_policy(f"qubes.USB+{device.port_id}", policy_line, True) 677 | try: 678 | # and actual attach 679 | try: 680 | await vm.run_service_for_stdio( 681 | "qubes.USBAttach", 682 | user="root", 683 | input=f"{device.backend_domain.name} " 684 | f"{device.port_id}\n".encode(), 685 | **extra_kwargs, 686 | ) 687 | except subprocess.CalledProcessError as e: 688 | # pylint: disable=raise-missing-from 689 | if e.returncode == 127: 690 | raise USBProxyNotInstalled( 691 | "qubes-usb-proxy not installed in the VM" 692 | ) 693 | raise QubesUSBException( 694 | f"Device attach failed: {sanitize_stderr_for_log(e.output)}" 695 | f" {sanitize_stderr_for_log(e.stderr)}" 696 | ) 697 | finally: 698 | modify_qrexec_policy( 699 | f"qubes.USB+{device.port_id}", policy_line, False 700 | ) 701 | 702 | @qubes.ext.handler("device-pre-detach:usb") 703 | async def on_device_detach_usb(self, vm, event, port): 704 | # pylint: disable=unused-argument 705 | if not vm.is_running() or vm.qid == 0: 706 | return 707 | 708 | for attached, _options in self.on_device_list_attached(vm, event): 709 | if attached.port == port: 710 | break 711 | else: 712 | raise QubesUSBException( 713 | f"Device {port} not connected to VM {vm.name}" 714 | ) 715 | 716 | # update the cache before the call, to avoid sending duplicated events 717 | # (one on qubesdb watch and the other by the caller of this method) 718 | backend = attached.backend_domain 719 | self.devices_cache[backend.name][attached.port_id] = None 720 | 721 | try: 722 | await backend.run_service_for_stdio( 723 | "qubes.USBDetach", 724 | user="root", 725 | input=f"{attached.port_id}\n".encode(), 726 | ) 727 | except subprocess.CalledProcessError as e: 728 | # pylint: disable=raise-missing-from 729 | raise QubesUSBException( 730 | f"Device detach failed: {sanitize_stderr_for_log(e.output)}" 731 | f" {sanitize_stderr_for_log(e.stderr)}" 732 | ) 733 | 734 | @qubes.ext.handler("device-pre-assign:usb") 735 | async def on_device_assign_usb(self, vm, event, device, options): 736 | # pylint: disable=unused-argument 737 | 738 | if options: 739 | raise qubes.exc.QubesException( 740 | "USB device assignment does not support user options" 741 | ) 742 | 743 | @qubes.ext.handler("domain-start") 744 | async def on_domain_start(self, vm, _event, **_kwargs): 745 | # pylint: disable=unused-argument 746 | to_attach = {} 747 | assignments = get_assigned_devices(vm.devices["usb"]) 748 | # the most specific assignments first 749 | for assignment in reversed(sorted(assignments)): 750 | for device in assignment.devices: 751 | if isinstance(device, qubes.device_protocol.UnknownDevice): 752 | continue 753 | if device.attachment: 754 | continue 755 | if not assignment.matches(device): 756 | print( 757 | "Unrecognized identity, skipping attachment of device " 758 | f"from the port {assignment}", 759 | file=sys.stderr, 760 | ) 761 | continue 762 | # chose first assignment (the most specific) and ignore rest 763 | if device not in to_attach: 764 | # make it unique 765 | to_attach[device] = assignment.clone(device=device) 766 | in_progress = set() 767 | for assignment in to_attach.values(): 768 | in_progress.add( 769 | asyncio.ensure_future(self.attach_and_notify(vm, assignment)) 770 | ) 771 | if in_progress: 772 | await asyncio.wait(in_progress) 773 | 774 | @qubes.ext.handler("domain-shutdown") 775 | async def on_domain_shutdown(self, vm, _event, **_kwargs): 776 | # pylint: disable=unused-argument 777 | vm.fire_event("device-list-change:usb") 778 | 779 | @qubes.ext.handler("qubes-close", system=True) 780 | def on_qubes_close(self, app, event): 781 | # pylint: disable=unused-argument 782 | self.devices_cache.clear() 783 | -------------------------------------------------------------------------------- /qubesusbproxy/tests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # vim: fileencoding=utf-8 3 | 4 | # 5 | # The Qubes OS Project, https://www.qubes-os.org/ 6 | # 7 | # Copyright (C) 2016 8 | # Marek Marczykowski-Górecki 9 | # 10 | # This program is free software; you can redistribute it and/or modify 11 | # it under the terms of the GNU General Public License as published by 12 | # the Free Software Foundation; either version 2 of the License, or 13 | # (at your option) any later version. 14 | # 15 | # This program is distributed in the hope that it will be useful, 16 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 17 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 18 | # GNU General Public License for more details. 19 | # 20 | # You should have received a copy of the GNU General Public License along 21 | # with this program; if not, write to the Free Software Foundation, Inc., 22 | # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 23 | # 24 | import time 25 | import unittest 26 | from unittest import mock 27 | from unittest.mock import Mock, AsyncMock 28 | 29 | import jinja2 30 | import qubes.tests.extra 31 | 32 | core3 = False 33 | LEGACY = False 34 | try: 35 | import qubesusbproxy.core3ext 36 | import asyncio 37 | 38 | try: 39 | from qubes.device_protocol import DeviceAssignment, VirtualDevice, Port 40 | 41 | def make_assignment(backend, ident, auto_attach=False): 42 | return DeviceAssignment( 43 | VirtualDevice(Port(backend, ident, "usb")), 44 | mode="auto-attach" if auto_attach else "manual", 45 | ) 46 | 47 | def assign(test, collection, assignment): 48 | test.loop.run_until_complete(collection.assign(assignment)) 49 | 50 | def unassign(test, collection, assignment): 51 | test.loop.run_until_complete(collection.unassign(assignment)) 52 | 53 | except ImportError: 54 | # This extension supports both the legacy and new device API. 55 | # In the case of the legacy backend, functionality is limited. 56 | from qubes.devices import DeviceAssignment 57 | 58 | def make_assignment(backend, ident, required=False): 59 | # pylint: disable=unexpected-keyword-arg 60 | return DeviceAssignment(backend, ident, persistent=required) 61 | 62 | def assign(test, collection, assignment): 63 | test.loop.run_until_complete(collection.attach(assignment)) 64 | 65 | def unassign(test, collection, assignment): 66 | test.loop.run_until_complete(collection.detach(assignment)) 67 | 68 | LEGACY = True 69 | 70 | core3 = True 71 | except ImportError: 72 | pass 73 | 74 | is_r40 = False 75 | try: 76 | with open("/etc/qubes-release") as f: 77 | if "R4.0" in f.read(): 78 | is_r40 = True 79 | except FileNotFoundError: 80 | pass 81 | 82 | GADGET_PREREQ = "&&".join( 83 | [ 84 | "modprobe dummy_hcd", 85 | "modprobe usb_f_mass_storage", 86 | "mount|grep -q configfs", 87 | "test -d /sys/class/udc/dummy_udc.0", 88 | ] 89 | ) 90 | 91 | GADGET_PREPARE = ";".join( 92 | [ 93 | "set -e -x", 94 | "cd /sys/kernel/config/usb_gadget", 95 | "mkdir test_g1; cd test_g1", 96 | "echo 0x1234 > idProduct", 97 | "echo 0x1234 > idVendor", 98 | "mkdir strings/0x409", 99 | "echo 0123456789 > strings/0x409/serialnumber", 100 | "echo Qubes > strings/0x409/manufacturer", 101 | "echo Test device > strings/0x409/product", 102 | "mkdir configs/c.1", 103 | "mkdir functions/mass_storage.ms1", 104 | "truncate -s 512M /var/tmp/test-file", 105 | "echo /var/tmp/test-file > functions/mass_storage.ms1/lun.0/file", 106 | "ln -s functions/mass_storage.ms1 configs/c.1", 107 | "echo dummy_udc.0 > UDC", 108 | "sleep 2; udevadm settle", 109 | ] 110 | ) 111 | 112 | 113 | def create_usb_gadget(vm): 114 | vm.start() 115 | p = vm.run( 116 | GADGET_PREREQ, user="root", passio_popen=True, passio_stderr=True 117 | ) 118 | (_, _stderr) = p.communicate() 119 | if p.returncode != 0: 120 | raise unittest.SkipTest("missing USB Gadget subsystem") 121 | p = vm.run( 122 | GADGET_PREPARE, user="root", passio_popen=True, passio_stderr=True 123 | ) 124 | (_, stderr) = p.communicate() 125 | if p.returncode != 0: 126 | raise RuntimeError("Failed to setup USB gadget: " + stderr.decode()) 127 | p = vm.run( 128 | "ls /sys/bus/platform/devices/dummy_hcd.0/usb*|grep -x .-.", 129 | passio_popen=True, 130 | ) 131 | (stdout, _) = p.communicate() 132 | stdout = stdout.strip() 133 | if not stdout: 134 | raise RuntimeError("Failed to get dummy device ID") 135 | return stdout 136 | 137 | 138 | def remove_usb_gadget(vm): 139 | assert vm.is_running() 140 | 141 | retcode = vm.run( 142 | "echo > /sys/kernel/config/usb_gadget/test_g1/UDC", 143 | user="root", 144 | wait=True, 145 | ) 146 | if retcode != 0: 147 | raise RuntimeError("Failed to disable USB gadget") 148 | 149 | 150 | def recreate_usb_gadget(vm): 151 | """Re-create the gadget previously created with *create_usb_gadget*, 152 | then removed with *remove_usb_gadget*. 153 | """ 154 | 155 | reconnect = ";".join( 156 | [ 157 | "cd /sys/kernel/config/usb_gadget", 158 | "mkdir test_g1; cd test_g1", 159 | "echo dummy_udc.0 > UDC", 160 | "sleep 2; udevadm settle", 161 | ] 162 | ) 163 | 164 | p = vm.run(reconnect, user="root", passio_popen=True, passio_stderr=True) 165 | (_, stderr) = p.communicate() 166 | if p.returncode != 0: 167 | raise RuntimeError("Failed to re-create USB gadget: " + stderr.decode()) 168 | 169 | 170 | class TC_00_USBProxy(qubes.tests.extra.ExtraTestCase): 171 | def setUp(self): 172 | if "whonix-gw" in self.template: 173 | self.skipTest("whonix-gw does not have qubes-usb-proxy") 174 | super().setUp() 175 | vms = self.create_vms(["backend", "frontend"]) 176 | (self.backend, self.frontend) = vms 177 | self.qrexec_policy("qubes.USB", self.frontend.name, self.backend.name) 178 | self.dummy_usb_dev = create_usb_gadget(self.backend).decode() 179 | 180 | def test_000_attach_detach(self): 181 | self.frontend.start() 182 | # TODO: check qubesdb entries 183 | self.assertEqual( 184 | self.frontend.run_service( 185 | "qubes.USBAttach", 186 | user="root", 187 | input=f"{self.backend.name} {self.dummy_usb_dev}\n", 188 | ), 189 | 0, 190 | "qubes.USBAttach call failed", 191 | ) 192 | self.assertEqual( 193 | self.frontend.run("lsusb -d 1234:1234", wait=True), 194 | 0, 195 | "Device connection failed", 196 | ) 197 | # TODO: check qubesdb entries 198 | self.assertEqual( 199 | self.frontend.run_service( 200 | "qubes.USBDetach", 201 | user="root", 202 | input=f"{self.backend.name} {self.dummy_usb_dev}\n", 203 | ), 204 | 0, 205 | "qubes.USBDetach call failed", 206 | ) 207 | self.assertEqual( 208 | self.frontend.run("lsusb -d 1234:1234", wait=True), 209 | 1, 210 | "Device disconnection failed", 211 | ) 212 | 213 | def test_010_attach_detach_vid_pid(self): 214 | self.frontend.start() 215 | # TODO: check qubesdb entries 216 | self.assertEqual( 217 | self.frontend.run_service( 218 | "qubes.USBAttach", 219 | user="root", 220 | input=f"{self.backend.name} 0x1234.0x1234\n", 221 | ), 222 | 0, 223 | "qubes.USBAttach call failed", 224 | ) 225 | self.assertEqual( 226 | self.frontend.run("lsusb -d 1234:1234", wait=True), 227 | 0, 228 | "Device connection failed", 229 | ) 230 | # TODO: check qubesdb entries 231 | self.assertEqual( 232 | self.frontend.run_service( 233 | "qubes.USBDetach", 234 | user="root", 235 | input=f"{self.backend.name} 0x1234.0x1234\n", 236 | ), 237 | 0, 238 | "qubes.USBDetach call failed", 239 | ) 240 | self.assertEqual( 241 | self.frontend.run("lsusb -d 1234:1234", wait=True), 242 | 1, 243 | "Device disconnection failed", 244 | ) 245 | 246 | def test_020_detach_on_remove(self): 247 | self.frontend.start() 248 | self.assertEqual( 249 | self.frontend.run_service( 250 | "qubes.USBAttach", 251 | user="root", 252 | input=f"{self.backend.name} {self.dummy_usb_dev}\n", 253 | ), 254 | 0, 255 | "qubes.USBAttach call failed", 256 | ) 257 | self.assertEqual( 258 | self.frontend.run("lsusb -d 1234:1234", wait=True), 259 | 0, 260 | "Device connection failed", 261 | ) 262 | remove_usb_gadget(self.backend) 263 | # FIXME: usb-export script may update qubesdb/disconnect with 1sec delay 264 | time.sleep(2) 265 | self.assertEqual( 266 | self.frontend.run("lsusb -d 1234:1234", wait=True), 267 | 1, 268 | "Device not cleaned up", 269 | ) 270 | # TODO: check for kernel errors? 271 | 272 | 273 | class TC_20_USBProxy_core3(qubes.tests.extra.ExtraTestCase): 274 | # noinspection PyAttributeOutsideInit 275 | def setUp(self): 276 | super().setUp() 277 | self.backend, self.frontend = self.create_vms(["backend", "frontend"]) 278 | self.qrexec_policy("qubes.USB", self.frontend.name, self.backend.name) 279 | self.usbdev_ident = create_usb_gadget(self.backend).decode() 280 | self.usbdev_name = ( 281 | f"{self.backend.name}:{self.usbdev_ident}" 282 | ":1234:1234:0123456789:u080650" 283 | ) 284 | 285 | def tearDown(self): 286 | # remove vms in this specific order, otherwise there may remain stray 287 | # dependency between them (so, objects leaks) 288 | self.remove_vms((self.frontend, self.backend)) 289 | 290 | super().tearDown() 291 | 292 | def test_000_list(self): 293 | usb_list = self.backend.devices["usb"] 294 | self.assertIn(self.usbdev_name, [str(dev) for dev in usb_list]) 295 | 296 | def test_010_assign(self): 297 | usb_dev = self.backend.devices["usb"][self.usbdev_ident] 298 | ass = make_assignment(self.backend, self.usbdev_ident, auto_attach=True) 299 | assign(self, self.frontend.devices["usb"], ass) 300 | self.assertIsNone(usb_dev.attachment) 301 | try: 302 | self.frontend.start() 303 | except qubesusbproxy.core3ext.USBProxyNotInstalled as e: 304 | self.skipTest(str(e)) 305 | 306 | self.assertEqual( 307 | self.frontend.run("lsusb -d 1234:1234", wait=True), 308 | 0, 309 | "Device connection failed", 310 | ) 311 | 312 | self.assertEqual(usb_dev.attachment, self.frontend) 313 | 314 | @unittest.mock.patch("qubes.ext.utils.confirm_device_attachment") 315 | @unittest.skipIf(LEGACY, "new feature") 316 | def test_011_assign_ask(self, confirm): 317 | confirm.return_value = self.frontend.name 318 | usb_dev = self.backend.devices["usb"][self.usbdev_ident] 319 | ass = DeviceAssignment( 320 | VirtualDevice(Port(self.backend, self.usbdev_ident, "usb")), 321 | mode="ask-to-attach", 322 | ) 323 | assign(self, self.frontend.devices["usb"], ass) 324 | self.assertIsNone(usb_dev.attachment) 325 | try: 326 | self.frontend.start() 327 | except qubesusbproxy.core3ext.USBProxyNotInstalled as e: 328 | self.skipTest(str(e)) 329 | 330 | self.assertEqual( 331 | self.frontend.run("lsusb -d 1234:1234", wait=True), 332 | 0, 333 | "Device connection failed", 334 | ) 335 | 336 | self.assertEqual(usb_dev.attachment, self.frontend) 337 | 338 | def test_020_attach(self): 339 | self.frontend.start() 340 | usb_dev = self.backend.devices["usb"][self.usbdev_ident] 341 | ass = make_assignment(self.backend, self.usbdev_ident) 342 | try: 343 | self.loop.run_until_complete( 344 | self.frontend.devices["usb"].attach(ass) 345 | ) 346 | except qubesusbproxy.core3ext.USBProxyNotInstalled as e: 347 | self.skipTest(str(e)) 348 | 349 | self.assertEqual( 350 | self.frontend.run("lsusb -d 1234:1234", wait=True), 351 | 0, 352 | "Device connection failed", 353 | ) 354 | 355 | self.assertEqual(usb_dev.attachment, self.frontend) 356 | 357 | def test_030_detach(self): 358 | self.frontend.start() 359 | usb_dev = self.backend.devices["usb"][self.usbdev_ident] 360 | ass = make_assignment(self.backend, self.usbdev_ident) 361 | try: 362 | self.loop.run_until_complete( 363 | self.frontend.devices["usb"].attach(ass) 364 | ) 365 | except qubesusbproxy.core3ext.USBProxyNotInstalled as e: 366 | self.skipTest(str(e)) 367 | 368 | self.loop.run_until_complete(self.frontend.devices["usb"].detach(ass)) 369 | # FIXME: usb-export script may update qubesdb with 1sec delay 370 | self.loop.run_until_complete(asyncio.sleep(2)) 371 | 372 | self.assertIsNone(usb_dev.attachment) 373 | 374 | self.assertNotEqual( 375 | self.frontend.run("lsusb -d 1234:1234", wait=True), 376 | 0, 377 | "Device disconnection failed", 378 | ) 379 | 380 | def test_040_unassign(self): 381 | usb_dev = self.backend.devices["usb"][self.usbdev_ident] 382 | ass = make_assignment(self.backend, self.usbdev_ident, auto_attach=True) 383 | assign(self, self.frontend.devices["usb"], ass) 384 | self.assertIsNone(usb_dev.attachment) 385 | unassign(self, self.frontend.devices["usb"], ass) 386 | self.assertIsNone(usb_dev.attachment) 387 | 388 | def test_050_list_attached(self): 389 | """Attached device should not be listed as further attachable""" 390 | self.frontend.start() 391 | usb_list = self.backend.devices["usb"] 392 | 393 | usb_list_front_pre = list(self.frontend.devices["usb"]) 394 | ass = make_assignment(self.backend, self.usbdev_ident) 395 | 396 | try: 397 | self.loop.run_until_complete( 398 | self.frontend.devices["usb"].attach(ass) 399 | ) 400 | except qubesusbproxy.core3ext.USBProxyNotInstalled as e: 401 | self.skipTest(str(e)) 402 | 403 | self.assertEqual( 404 | self.frontend.run("lsusb -d 1234:1234", wait=True), 405 | 0, 406 | "Device connection failed", 407 | ) 408 | 409 | self.assertEqual(usb_list[self.usbdev_ident].attachment, self.frontend) 410 | 411 | usb_list_front_post = list(self.frontend.devices["usb"]) 412 | 413 | self.assertEqual(usb_list_front_pre, usb_list_front_post) 414 | 415 | def test_060_auto_detach_on_remove(self): 416 | self.frontend.start() 417 | usb_list = self.backend.devices["usb"] 418 | ass = make_assignment(self.backend, self.usbdev_ident) 419 | try: 420 | self.loop.run_until_complete( 421 | self.frontend.devices["usb"].attach(ass) 422 | ) 423 | except qubesusbproxy.core3ext.USBProxyNotInstalled as e: 424 | self.skipTest(str(e)) 425 | 426 | remove_usb_gadget(self.backend) 427 | # FIXME: usb-export script may update qubesdb with 1sec delay 428 | self.loop.run_until_complete(asyncio.sleep(2)) 429 | 430 | self.assertNotIn(self.usbdev_name, [str(dev) for dev in usb_list]) 431 | self.assertNotEqual( 432 | self.frontend.run("lsusb -d 1234:1234", wait=True), 433 | 0, 434 | "Device disconnection failed", 435 | ) 436 | 437 | def test_061_auto_attach_on_reconnect(self): 438 | self.frontend.start() 439 | usb_list = self.backend.devices["usb"] 440 | ass = make_assignment(self.backend, self.usbdev_ident, auto_attach=True) 441 | try: 442 | assign(self, self.frontend.devices["usb"], ass) 443 | except qubesusbproxy.core3ext.USBProxyNotInstalled as e: 444 | self.skipTest(str(e)) 445 | 446 | remove_usb_gadget(self.backend) 447 | # FIXME: usb-export script may update qubesdb with 1sec delay 448 | timeout = 5 449 | while self.usbdev_name in (str(dev) for dev in usb_list): 450 | self.loop.run_until_complete(asyncio.sleep(1)) 451 | timeout -= 1 452 | self.assertGreater(timeout, 0, "timeout on device remove") 453 | 454 | recreate_usb_gadget(self.backend) 455 | timeout = 5 456 | while self.usbdev_name not in (str(dev) for dev in usb_list): 457 | self.loop.run_until_complete(asyncio.sleep(1)) 458 | timeout -= 1 459 | self.assertGreater(timeout, 0, "timeout on device create") 460 | self.loop.run_until_complete(asyncio.sleep(5)) 461 | self.assertEqual( 462 | self.frontend.run("lsusb -d 1234:1234", wait=True), 463 | 0, 464 | "Device reconnection failed", 465 | ) 466 | 467 | def test_062_ask_to_attach_on_start(self): 468 | self.frontend.start() 469 | usb_list = self.backend.devices["usb"] 470 | ass = make_assignment(self.backend, self.usbdev_ident, auto_attach=True) 471 | try: 472 | assign(self, self.frontend.devices["usb"], ass) 473 | except qubesusbproxy.core3ext.USBProxyNotInstalled as e: 474 | self.skipTest(str(e)) 475 | 476 | remove_usb_gadget(self.backend) 477 | # FIXME: usb-export script may update qubesdb with 1sec delay 478 | timeout = 5 479 | while self.usbdev_name in (str(dev) for dev in usb_list): 480 | self.loop.run_until_complete(asyncio.sleep(1)) 481 | timeout -= 1 482 | self.assertGreater(timeout, 0, "timeout on device remove") 483 | 484 | recreate_usb_gadget(self.backend) 485 | timeout = 5 486 | while self.usbdev_name not in (str(dev) for dev in usb_list): 487 | self.loop.run_until_complete(asyncio.sleep(1)) 488 | timeout -= 1 489 | self.assertGreater(timeout, 0, "timeout on device create") 490 | self.loop.run_until_complete(asyncio.sleep(5)) 491 | self.assertEqual( 492 | self.frontend.run("lsusb -d 1234:1234", wait=True), 493 | 0, 494 | "Device reconnection failed", 495 | ) 496 | 497 | def test_070_attach_not_installed_front(self): 498 | self.frontend.start() 499 | # simulate package not installed 500 | retcode = self.frontend.run( 501 | "rm -f /etc/qubes-rpc/qubes.USBAttach", user="root", wait=True 502 | ) 503 | if retcode != 0: 504 | raise RuntimeError("Failed to simulate not installed package") 505 | ass = make_assignment(self.backend, self.usbdev_ident) 506 | with self.assertRaises(qubesusbproxy.core3ext.USBProxyNotInstalled): 507 | self.loop.run_until_complete( 508 | self.frontend.devices["usb"].attach(ass) 509 | ) 510 | 511 | @unittest.expectedFailure 512 | def test_075_attach_not_installed_back(self): 513 | self.frontend.start() 514 | # simulate package not installed 515 | retcode = self.backend.run( 516 | "rm -f /etc/qubes-rpc/qubes.USB", user="root", wait=True 517 | ) 518 | if retcode != 0: 519 | raise RuntimeError("Failed to simulate not installed package") 520 | ass = make_assignment(self.backend, self.usbdev_ident) 521 | try: 522 | with self.assertRaises(qubesusbproxy.core3ext.USBProxyNotInstalled): 523 | self.loop.run_until_complete( 524 | self.frontend.devices["usb"].attach(ass) 525 | ) 526 | except qubesusbproxy.core3ext.QubesUSBException as e: 527 | self.fail( 528 | "Generic exception raise instead of specific " 529 | "USBProxyNotInstalled: " + str(e) 530 | ) 531 | 532 | def test_080_attach_existing_policy(self): 533 | self.frontend.start() 534 | # this override policy file, but during normal execution it shouldn't 535 | # exist, so should be ok, especially on a testing system 536 | with open( 537 | f"/etc/qubes-rpc/policy/qubes.USB+{self.usbdev_ident}", "w+" 538 | ) as policy_file: 539 | policy_file.write("# empty policy\n") 540 | ass = make_assignment(self.backend, self.usbdev_ident) 541 | self.loop.run_until_complete(self.frontend.devices["usb"].attach(ass)) 542 | 543 | @unittest.skipIf(is_r40, "Not supported on R4.0") 544 | def test_090_attach_stubdom(self): 545 | self.frontend.virt_mode = "hvm" 546 | self.frontend.features["stubdom-qrexec"] = True 547 | self.frontend.start() 548 | ass = make_assignment(self.backend, self.usbdev_ident) 549 | try: 550 | self.loop.run_until_complete( 551 | self.frontend.devices["usb"].attach(ass) 552 | ) 553 | except qubesusbproxy.core3ext.USBProxyNotInstalled as e: 554 | self.skipTest(str(e)) 555 | 556 | time.sleep(5) 557 | self.assertEqual( 558 | self.frontend.run("lsusb -d 1234:1234", wait=True), 559 | 0, 560 | "Device connection failed", 561 | ) 562 | 563 | 564 | class TestQubesDB: 565 | def __init__(self, data): 566 | self._data = data 567 | 568 | def read(self, key): 569 | return self._data.get(key, None) 570 | 571 | def list(self, prefix): 572 | return [key for key in self._data if key.startswith(prefix)] 573 | 574 | 575 | class TestApp: 576 | class Domains(dict): 577 | def __iter__(self): 578 | return iter(self.values()) 579 | 580 | def __init__(self): 581 | #: jinja2 environment for libvirt XML templates 582 | self.env = jinja2.Environment( 583 | loader=jinja2.FileSystemLoader( 584 | [ 585 | "templates", 586 | "/etc/qubes/templates", 587 | "/usr/share/qubes/templates", 588 | ] 589 | ), 590 | undefined=jinja2.StrictUndefined, 591 | autoescape=True, 592 | ) 593 | self.domains = TestApp.Domains() 594 | self.vmm = mock.Mock() 595 | 596 | 597 | class TestDeviceCollection: 598 | def __init__(self, backend_vm, devclass): 599 | self._exposed = [] 600 | self._assigned = [] 601 | self.backend_vm = backend_vm 602 | self.devclass = devclass 603 | 604 | def get_assigned_devices(self): 605 | return self._assigned 606 | 607 | def get_exposed_devices(self): 608 | yield from self._exposed 609 | 610 | __iter__ = get_exposed_devices 611 | 612 | def __getitem__(self, port_id): 613 | for dev in self._exposed: 614 | if dev.port_id == port_id: 615 | return dev 616 | raise KeyError() 617 | 618 | 619 | class TestVM(qubes.tests.TestEmitter): 620 | def __init__(self, qdb, running=True, name="test-vm", **kwargs): 621 | super().__init__(**kwargs) 622 | self.name = name 623 | self.klass = "AdminVM" if name == "dom0" else "AppVM" 624 | self.icon = "red" 625 | self.untrusted_qdb = TestQubesDB(qdb) 626 | self.libvirt_domain = mock.Mock() 627 | self.features = mock.Mock() 628 | self.features.check_with_template.side_effect = lambda name, default: ( 629 | "4.2" if name == "qubes-agent-version" else None 630 | ) 631 | self.is_running = lambda: running 632 | self.log = mock.Mock() 633 | self.app = TestApp() 634 | self.devices = {"testclass": TestDeviceCollection(self, "testclass")} 635 | 636 | def __hash__(self): 637 | return hash(self.name) 638 | 639 | def __eq__(self, other): 640 | if isinstance(other, TestVM): 641 | return self.name == other.name 642 | return False 643 | 644 | def __str__(self): 645 | return self.name 646 | 647 | 648 | def get_qdb(attachment=None): 649 | result = { 650 | "/qubes-usb-devices/1-1/desc": b"1a0a:badd USB-IF Test\x20Device", 651 | "/qubes-usb-devices/1-1/interfaces": b":ffff00:020600:0a0000:", 652 | "/qubes-usb-devices/1-1/usb-ver": b"2", 653 | "/qubes-usb-devices/1-2/desc": b"1a0a:badd USB-IF Test\x20Device\x202", 654 | "/qubes-usb-devices/1-2/interfaces": b":0acafe:", 655 | "/qubes-usb-devices/1-2/usb-ver": b"3", 656 | } 657 | if attachment: 658 | result["/qubes-usb-devices/1-1/connected-to"] = attachment.encode() 659 | return result 660 | 661 | 662 | class TC_30_USBProxy_core3(qubes.tests.QubesTestCase): 663 | # noinspection PyAttributeOutsideInit 664 | def setUp(self): 665 | super().setUp() 666 | self.ext = qubesusbproxy.core3ext.USBDeviceExtension() 667 | 668 | @staticmethod 669 | def added_assign_setup(attachment=None): 670 | back_vm = TestVM(qdb=get_qdb(attachment), name="sys-usb") 671 | front = TestVM({}, name="front-vm") 672 | dom0 = TestVM({}, name="dom0") 673 | back_vm.app.domains["sys-usb"] = back_vm 674 | back_vm.app.domains["front-vm"] = front 675 | back_vm.app.domains[0] = dom0 676 | back_vm.app.domains["dom0"] = dom0 677 | front.app = back_vm.app 678 | dom0.app = back_vm.app 679 | 680 | back_vm.app.vmm.configure_mock(**{"offline_mode": False}) 681 | fire_event_async = mock.Mock() 682 | front.fire_event_async = fire_event_async 683 | 684 | back_vm.devices["usb"] = TestDeviceCollection( 685 | backend_vm=back_vm, devclass="usb" 686 | ) 687 | front.devices["usb"] = TestDeviceCollection( 688 | backend_vm=front, devclass="usb" 689 | ) 690 | dom0.devices["usb"] = TestDeviceCollection( 691 | backend_vm=dom0, devclass="usb" 692 | ) 693 | 694 | return back_vm, front 695 | 696 | def test_010_on_qdb_change_multiple_assignments_including_full(self): 697 | back, front = self.added_assign_setup() 698 | 699 | exp_dev = qubesusbproxy.core3ext.USBDevice(Port(back, "1-1", "usb")) 700 | full_assig = DeviceAssignment( 701 | VirtualDevice(exp_dev.port, exp_dev.device_id), 702 | mode="auto-attach", 703 | options={"pid": "did"}, 704 | ) 705 | port_assign = DeviceAssignment( 706 | VirtualDevice(exp_dev.port, "*"), 707 | mode="auto-attach", 708 | options={"pid": "any"}, 709 | ) 710 | dev_assign = DeviceAssignment( 711 | VirtualDevice( 712 | Port(exp_dev.backend_domain, "*", "usb"), exp_dev.device_id 713 | ), 714 | mode="auto-attach", 715 | options={"any": "did"}, 716 | ) 717 | 718 | front.devices["usb"]._assigned.append(dev_assign) 719 | front.devices["usb"]._assigned.append(port_assign) 720 | front.devices["usb"]._assigned.append(full_assig) 721 | back.devices["usb"]._exposed.append( 722 | qubesusbproxy.core3ext.USBDevice(Port(back, "1-1", "usb")) 723 | ) 724 | 725 | loop = asyncio.get_event_loop() 726 | with (mock.patch.object(self.ext, "attach_and_notify") 727 | as attach_and_notify): 728 | loop.run_until_complete(self.ext.on_domain_start(front, None)) 729 | self.assertEqual( 730 | attach_and_notify.call_args[0][1].options, {"pid": "did"} 731 | ) 732 | 733 | def test_011_on_qdb_change_multiple_assignments_port_vs_dev(self): 734 | back, front = self.added_assign_setup() 735 | 736 | exp_dev = qubesusbproxy.core3ext.USBDevice(Port(back, "1-1", "usb")) 737 | port_assign = DeviceAssignment( 738 | VirtualDevice(exp_dev.port, "*"), 739 | mode="auto-attach", 740 | options={"pid": "any"}, 741 | ) 742 | dev_assign = DeviceAssignment( 743 | VirtualDevice( 744 | Port(exp_dev.backend_domain, "*", "usb"), exp_dev.device_id 745 | ), 746 | mode="auto-attach", 747 | options={"any": "did"}, 748 | ) 749 | 750 | front.devices["usb"]._assigned.append(dev_assign) 751 | front.devices["usb"]._assigned.append(port_assign) 752 | back.devices["usb"]._exposed.append( 753 | qubesusbproxy.core3ext.USBDevice(Port(back, "1-1", "usb")) 754 | ) 755 | 756 | loop = asyncio.get_event_loop() 757 | with (mock.patch.object(self.ext, "attach_and_notify") 758 | as attach_and_notify): 759 | loop.run_until_complete(self.ext.on_domain_start(front, None)) 760 | self.assertEqual( 761 | attach_and_notify.call_args[0][1].options, {"pid": "any"} 762 | ) 763 | 764 | def test_012_on_qdb_change_multiple_assignments_dev(self): 765 | back, front = self.added_assign_setup() 766 | 767 | exp_dev = qubesusbproxy.core3ext.USBDevice(Port(back, "1-1", "usb")) 768 | port_assign = DeviceAssignment( 769 | VirtualDevice(Port(exp_dev.backend_domain, "1-2", "usb"), "*"), 770 | mode="auto-attach", 771 | options={"pid": "any"}, 772 | ) 773 | dev_assign = DeviceAssignment( 774 | VirtualDevice( 775 | Port(exp_dev.backend_domain, "*", "usb"), exp_dev.device_id 776 | ), 777 | mode="auto-attach", 778 | options={"any": "did"}, 779 | ) 780 | 781 | front.devices["usb"]._assigned.append(dev_assign) 782 | front.devices["usb"]._assigned.append(port_assign) 783 | back.devices["usb"]._exposed.append( 784 | qubesusbproxy.core3ext.USBDevice(Port(back, "1-1", "usb")) 785 | ) 786 | back.devices["usb"]._exposed.append( 787 | qubesusbproxy.core3ext.USBDevice(Port(back, "1-2", "usb")) 788 | ) 789 | 790 | loop = asyncio.get_event_loop() 791 | with (mock.patch.object(self.ext, "attach_and_notify") 792 | as attach_and_notify): 793 | loop.run_until_complete(self.ext.on_domain_start(front, None)) 794 | self.assertEqual( 795 | attach_and_notify.call_args[0][1].options, {"any": "did"} 796 | ) 797 | 798 | def test_013_on_qdb_change_two_fronts(self): 799 | back, front = self.added_assign_setup() 800 | 801 | exp_dev = qubesusbproxy.core3ext.USBDevice(Port(back, "1-1", "usb")) 802 | assmnt = DeviceAssignment(exp_dev, mode="auto-attach") 803 | 804 | front.devices["usb"]._assigned.append(assmnt) 805 | back.devices["usb"]._assigned.append(assmnt) 806 | back.devices["usb"]._exposed.append(exp_dev) 807 | 808 | resolver_path = "qubes.ext.utils.resolve_conflicts_and_attach" 809 | with mock.patch(resolver_path, new_callable=Mock) as resolver: 810 | with mock.patch("asyncio.ensure_future"): 811 | self.ext.on_qdb_change(back, None, None) 812 | resolver.assert_called_once_with( 813 | self.ext, {"1-1": {front: assmnt, back: assmnt}} 814 | ) 815 | 816 | # call_socket_service returns coroutine 817 | @unittest.mock.patch( 818 | 'qubes.ext.utils.call_socket_service', new_callable=AsyncMock) 819 | def test_014_failed_confirmation(self, socket): 820 | back, front = self.added_assign_setup() 821 | 822 | exp_dev = qubesusbproxy.core3ext.USBDevice(Port(back, "1-1", "usb")) 823 | assmnt = DeviceAssignment(exp_dev, mode="auto-attach") 824 | 825 | front.devices["usb"]._assigned.append(assmnt) 826 | back.devices["usb"]._assigned.append(assmnt) 827 | back.devices["usb"]._exposed.append(exp_dev) 828 | 829 | socket.return_value = "allow:nonsense" 830 | 831 | loop = asyncio.get_event_loop() 832 | with (mock.patch.object(self.ext, "attach_and_notify") 833 | as attach_and_notify): 834 | loop.run_until_complete( 835 | qubes.ext.utils.resolve_conflicts_and_attach( 836 | self.ext, {"1-1": {front: assmnt, back: assmnt}} 837 | ) 838 | ) 839 | attach_and_notify.assert_not_called() 840 | 841 | # call_socket_service returns coroutine 842 | @unittest.mock.patch( 843 | 'qubes.ext.utils.call_socket_service', new_callable=AsyncMock) 844 | def test_015_successful_confirmation(self, socket): 845 | back, front = self.added_assign_setup() 846 | 847 | exp_dev = qubesusbproxy.core3ext.USBDevice(Port(back, "1-1", "usb")) 848 | assmnt = DeviceAssignment(exp_dev, mode="auto-attach") 849 | 850 | front.devices["usb"]._assigned.append(assmnt) 851 | back.devices["usb"]._assigned.append(assmnt) 852 | back.devices["usb"]._exposed.append(exp_dev) 853 | 854 | socket.return_value = "allow:front-vm" 855 | 856 | loop = asyncio.get_event_loop() 857 | with (mock.patch.object(self.ext, "attach_and_notify") 858 | as attach_and_notify): 859 | loop.run_until_complete( 860 | qubes.ext.utils.resolve_conflicts_and_attach( 861 | self.ext, {"1-1": {front: assmnt, back: assmnt}} 862 | ) 863 | ) 864 | attach_and_notify.assert_called_once_with(front, assmnt) 865 | 866 | def test_016_on_qdb_change_ask(self): 867 | back, front = self.added_assign_setup() 868 | 869 | exp_dev = qubesusbproxy.core3ext.USBDevice(Port(back, "1-1", "usb")) 870 | assmnt = DeviceAssignment(exp_dev, mode="ask-to-attach") 871 | 872 | front.devices["usb"]._assigned.append(assmnt) 873 | back.devices["usb"]._exposed.append(exp_dev) 874 | 875 | resolver_path = "qubes.ext.utils.resolve_conflicts_and_attach" 876 | with mock.patch(resolver_path, new_callable=Mock) as resolver: 877 | with mock.patch("asyncio.ensure_future"): 878 | self.ext.on_qdb_change(back, None, None) 879 | resolver.assert_called_once_with(self.ext, {"1-1": {front: assmnt}}) 880 | 881 | def test_020_on_startup_multiple_assignments_including_full(self): 882 | back, front = self.added_assign_setup() 883 | 884 | exp_dev = qubesusbproxy.core3ext.USBDevice(Port(back, "1-1", "usb")) 885 | full_assig = DeviceAssignment( 886 | VirtualDevice(exp_dev.port, exp_dev.device_id), 887 | mode="auto-attach", 888 | options={"pid": "did"}, 889 | ) 890 | port_assign = DeviceAssignment( 891 | VirtualDevice(exp_dev.port, "*"), 892 | mode="auto-attach", 893 | options={"pid": "any"}, 894 | ) 895 | dev_assign = DeviceAssignment( 896 | VirtualDevice( 897 | Port(exp_dev.backend_domain, "*", "usb"), exp_dev.device_id 898 | ), 899 | mode="auto-attach", 900 | options={"any": "did"}, 901 | ) 902 | 903 | front.devices["usb"]._assigned.append(dev_assign) 904 | front.devices["usb"]._assigned.append(port_assign) 905 | front.devices["usb"]._assigned.append(full_assig) 906 | back.devices["usb"]._exposed.append( 907 | qubesusbproxy.core3ext.USBDevice(Port(back, "1-1", "usb")) 908 | ) 909 | 910 | loop = asyncio.get_event_loop() 911 | with (mock.patch.object(self.ext, "attach_and_notify") 912 | as attach_and_notify): 913 | loop.run_until_complete(self.ext.on_domain_start(front, None)) 914 | self.assertEqual( 915 | attach_and_notify.call_args[0][1].options, {"pid": "did"} 916 | ) 917 | 918 | def test_021_on_startup_multiple_assignments_port_vs_dev(self): 919 | back, front = self.added_assign_setup() 920 | 921 | exp_dev = qubesusbproxy.core3ext.USBDevice(Port(back, "1-1", "usb")) 922 | port_assign = DeviceAssignment( 923 | VirtualDevice(exp_dev.port, "*"), 924 | mode="auto-attach", 925 | options={"pid": "any"}, 926 | ) 927 | dev_assign = DeviceAssignment( 928 | VirtualDevice( 929 | Port(exp_dev.backend_domain, "*", "usb"), exp_dev.device_id 930 | ), 931 | mode="auto-attach", 932 | options={"any": "did"}, 933 | ) 934 | 935 | front.devices["usb"]._assigned.append(dev_assign) 936 | front.devices["usb"]._assigned.append(port_assign) 937 | back.devices["usb"]._exposed.append( 938 | qubesusbproxy.core3ext.USBDevice(Port(back, "1-1", "usb")) 939 | ) 940 | 941 | loop = asyncio.get_event_loop() 942 | with (mock.patch.object(self.ext, "attach_and_notify") 943 | as attach_and_notify): 944 | loop.run_until_complete(self.ext.on_domain_start(front, None)) 945 | self.assertEqual( 946 | attach_and_notify.call_args[0][1].options, {"pid": "any"} 947 | ) 948 | 949 | def test_022_on_startup_multiple_assignments_dev(self): 950 | back, front = self.added_assign_setup() 951 | 952 | exp_dev = qubesusbproxy.core3ext.USBDevice(Port(back, "1-1", "usb")) 953 | port_assign = DeviceAssignment( 954 | VirtualDevice(Port(exp_dev.backend_domain, "1-2", "usb"), "*"), 955 | mode="auto-attach", 956 | options={"pid": "any"}, 957 | ) 958 | dev_assign = DeviceAssignment( 959 | VirtualDevice( 960 | Port(exp_dev.backend_domain, "*", "usb"), exp_dev.device_id 961 | ), 962 | mode="auto-attach", 963 | options={"any": "did"}, 964 | ) 965 | 966 | front.devices["usb"]._assigned.append(dev_assign) 967 | front.devices["usb"]._assigned.append(port_assign) 968 | back.devices["usb"]._exposed.append( 969 | qubesusbproxy.core3ext.USBDevice(Port(back, "1-1", "usb")) 970 | ) 971 | back.devices["usb"]._exposed.append( 972 | qubesusbproxy.core3ext.USBDevice(Port(back, "1-2", "usb")) 973 | ) 974 | 975 | loop = asyncio.get_event_loop() 976 | with (mock.patch.object(self.ext, "attach_and_notify") 977 | as attach_and_notify): 978 | loop.run_until_complete(self.ext.on_domain_start(front, None)) 979 | self.assertEqual( 980 | attach_and_notify.call_args[0][1].options, {"any": "did"} 981 | ) 982 | 983 | def test_023_on_startup_already_attached(self): 984 | back, front = self.added_assign_setup(attachment="sys-usb") 985 | 986 | exp_dev = qubesusbproxy.core3ext.USBDevice(Port(back, "1-1", "usb")) 987 | assmnt = DeviceAssignment( 988 | VirtualDevice(exp_dev.port, exp_dev.device_id), mode="auto-attach" 989 | ) 990 | 991 | front.devices["usb"]._assigned.append(assmnt) 992 | back.devices["usb"]._exposed.append(exp_dev) 993 | 994 | loop = asyncio.get_event_loop() 995 | with (mock.patch.object(self.ext, "attach_and_notify") 996 | as attach_and_notify): 997 | loop.run_until_complete(self.ext.on_domain_start(front, None)) 998 | attach_and_notify.assert_not_called() 999 | 1000 | 1001 | def list_tests(): 1002 | tests = [TC_00_USBProxy] 1003 | if core3: 1004 | tests += [TC_20_USBProxy_core3] 1005 | return tests 1006 | 1007 | 1008 | def list_unit_tests(): 1009 | tests = [] 1010 | if core3: 1011 | tests += [TC_30_USBProxy_core3] 1012 | return tests 1013 | -------------------------------------------------------------------------------- /qubesusbproxy/utils.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | # 3 | # The Qubes OS Project, https://www.qubes-os.org 4 | # 5 | # Copyright (C) 2024 Piotr Bartman-Szwarc 6 | # 7 | # This program is free software; you can redistribute it and/or 8 | # modify it under the terms of the GNU General Public License 9 | # as published by the Free Software Foundation; either version 2 10 | # of the License, or (at your option) any later version. 11 | # 12 | # This program is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License 18 | # along with this program; if not, write to the Free Software 19 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, 20 | # USA. 21 | import asyncio 22 | import sys 23 | 24 | import qubes 25 | 26 | from typing import Type, Dict, Any 27 | 28 | from qubes import device_protocol 29 | from qubes.device_protocol import VirtualDevice 30 | 31 | from qrexec.server import call_socket_service 32 | 33 | SOCKET_PATH = "/var/run/qubes" 34 | 35 | 36 | def device_list_change( 37 | ext: qubes.ext.Extension, 38 | current_devices, 39 | vm, 40 | path, 41 | device_class: Type[qubes.device_protocol.DeviceInfo], 42 | ): 43 | devclass = device_class.__name__[: -len("Device")].lower() 44 | 45 | if path is not None: 46 | vm.fire_event(f"device-list-change:{devclass}") 47 | 48 | added, attached, detached, removed = compare_device_cache( 49 | vm, ext.devices_cache, current_devices 50 | ) 51 | 52 | # send events about devices detached/attached outside by themselves 53 | for port_id, front_vm in detached.items(): 54 | dev = device_class(vm, port_id) 55 | ext.ensure_detach(front_vm, dev.port) 56 | asyncio.ensure_future( 57 | front_vm.fire_event_async( 58 | f"device-detach:{devclass}", port=dev.port 59 | ) 60 | ) 61 | for port_id in removed: 62 | device = device_class(vm, port_id) 63 | vm.fire_event(f"device-removed:{devclass}", port=device.port) 64 | for port_id in added: 65 | device = device_class(vm, port_id) 66 | vm.fire_event(f"device-added:{devclass}", device=device) 67 | for port_id, front_vm in attached.items(): 68 | dev = device_class(vm, port_id) 69 | # options are unknown, device already attached 70 | asyncio.ensure_future( 71 | front_vm.fire_event_async( 72 | f"device-attach:{devclass}", device=dev, options={} 73 | ) 74 | ) 75 | 76 | ext.devices_cache[vm.name] = current_devices 77 | 78 | to_attach: Dict[str, Dict] = {} 79 | for front_vm in vm.app.domains: 80 | if not front_vm.is_running(): 81 | continue 82 | for assignment in reversed( 83 | sorted(front_vm.devices[devclass].get_assigned_devices()) 84 | ): 85 | for device in assignment.devices: 86 | if ( 87 | assignment.matches(device) 88 | and device.port_id in added 89 | and device.port_id not in attached 90 | ): 91 | frontends = to_attach.get(device.port_id, {}) 92 | # make it unique 93 | ass = assignment.clone( 94 | device=VirtualDevice(device.port, device.device_id) 95 | ) 96 | curr = frontends.get(front_vm, None) 97 | if curr is None or curr < ass: 98 | # chose the most specific assignment 99 | frontends[front_vm] = ass 100 | to_attach[device.port_id] = frontends 101 | 102 | asyncio.ensure_future(resolve_conflicts_and_attach(ext, to_attach)) 103 | 104 | 105 | async def resolve_conflicts_and_attach(ext, to_attach): 106 | for _, frontends in to_attach.items(): 107 | if len(frontends) > 1: 108 | # unique 109 | device = tuple(frontends.values())[0].device 110 | target_name = await confirm_device_attachment(device, frontends) 111 | for front in frontends: 112 | if front.name == target_name: 113 | target = front 114 | assignment = frontends[front] 115 | # already asked 116 | if assignment.mode.value == "ask-to-attach": 117 | assignment.mode = device_protocol.AssignmentMode.AUTO 118 | break 119 | else: 120 | return 121 | else: 122 | target = tuple(frontends.keys())[0] 123 | assignment = frontends[target] 124 | 125 | await ext.attach_and_notify(target, assignment) 126 | 127 | 128 | def compare_device_cache(vm, devices_cache, current_devices): 129 | # compare cached devices and current devices, collect: 130 | # - newly appeared devices (port_id) 131 | # - devices attached from a vm to frontend vm (port_id: frontend_vm) 132 | # - devices detached from frontend vm (port_id: frontend_vm) 133 | # - disappeared devices, e.g., plugged out (port_id) 134 | added = set() 135 | attached = {} 136 | detached = {} 137 | removed = set() 138 | cache = devices_cache[vm.name] 139 | for dev_id, front_vm in current_devices.items(): 140 | if dev_id not in cache: 141 | added.add(dev_id) 142 | if front_vm is not None: 143 | attached[dev_id] = front_vm 144 | elif cache[dev_id] != front_vm: 145 | cached_front = cache[dev_id] 146 | if front_vm is None: 147 | detached[dev_id] = cached_front 148 | elif cached_front is None: 149 | attached[dev_id] = front_vm 150 | else: 151 | # a front changed from one to another, so we signal it as: 152 | # detach from the first one and attach to the second one. 153 | detached[dev_id] = cached_front 154 | attached[dev_id] = front_vm 155 | 156 | for dev_id, cached_front in cache.items(): 157 | if dev_id not in current_devices: 158 | removed.add(dev_id) 159 | if cached_front is not None: 160 | detached[dev_id] = cached_front 161 | return added, attached, detached, removed 162 | 163 | 164 | async def confirm_device_attachment(device, frontends) -> str: 165 | try: 166 | return await _do_confirm_device_attachment(device, frontends) 167 | except Exception as exc: 168 | print(str(exc.__class__.__name__) + ":", str(exc), file=sys.stderr) 169 | return "" 170 | 171 | 172 | async def _do_confirm_device_attachment(device, frontends): 173 | socket = "device-agent.GUI" 174 | 175 | app = tuple(frontends.keys())[0].app 176 | doms = app.domains 177 | 178 | front_names = [f.name for f in frontends.keys()] 179 | 180 | try: 181 | guivm = doms["dom0"].guivm.name 182 | except AttributeError: 183 | guivm = "dom0" 184 | 185 | number_of_targets = len(front_names) 186 | 187 | params = { 188 | "source": device.backend_domain.name, 189 | "device_name": device.description, 190 | "argument": device.port_id, 191 | "targets": front_names, 192 | "default_target": front_names[0] if number_of_targets == 1 else "", 193 | "icons": { 194 | ( 195 | dom.name if dom.klass != "DispVM" else f"@dispvm:{dom.name}" 196 | ): dom.icon 197 | for dom in doms.values() 198 | }, 199 | } 200 | 201 | socked_call = asyncio.create_task( 202 | call_socket_service(guivm, socket, "dom0", params, SOCKET_PATH) 203 | ) 204 | 205 | while not socked_call.done(): 206 | await asyncio.sleep(0.1) 207 | 208 | ask_response = await socked_call 209 | 210 | if ask_response.startswith("allow:"): 211 | chosen = ask_response[len("allow:") :] 212 | if chosen in front_names: 213 | return chosen 214 | return "" 215 | -------------------------------------------------------------------------------- /rpm_spec/qubes-usb-proxy-dom0.spec.in: -------------------------------------------------------------------------------- 1 | Name: qubes-usb-proxy-dom0 2 | Version: @VERSION@ 3 | Release: 1%{?dist} 4 | Summary: USBIP wrapper to run it over Qubes RPC connection - dom0 files 5 | 6 | Group: System 7 | License: GPLv2 8 | URL: https://www.qubes-os.org/ 9 | BuildArch: noarch 10 | 11 | BuildRequires: make 12 | BuildRequires: python3-devel 13 | BuildRequires: python3-setuptools 14 | 15 | Requires: qubes-core-dom0 >= 4.3.12 16 | 17 | Source0: qubes-usb-proxy-%{version}.tar.gz 18 | 19 | %description 20 | Dom0 files for Qubes USBIP wrapper. This includes Qubes tools integration. 21 | This package also contains tests. 22 | 23 | %prep 24 | %setup -q -n qubes-usb-proxy-%{version} 25 | 26 | %install 27 | make install-dom0 DESTDIR=${RPM_BUILD_ROOT} 28 | 29 | %files 30 | %attr(0664,root,qubes) %config(noreplace) /etc/qubes-rpc/policy/qubes.USB 31 | %dir %{python3_sitelib}/qubesusbproxy-*.egg-info 32 | %{python3_sitelib}/qubesusbproxy-*.egg-info/* 33 | %{python3_sitelib}/qubesusbproxy 34 | 35 | %changelog 36 | @CHANGELOG@ 37 | -------------------------------------------------------------------------------- /rpm_spec/qubes-usb-proxy.spec.in: -------------------------------------------------------------------------------- 1 | Name: qubes-usb-proxy 2 | Version: @VERSION@ 3 | Release: 1%{?dist} 4 | Summary: USBIP wrapper to run it over Qubes RPC connection 5 | 6 | Group: System 7 | License: GPLv2 8 | URL: https://www.qubes-os.org/ 9 | BuildArch: noarch 10 | 11 | BuildRequires: make 12 | %if 0%{?is_opensuse} 13 | # for directory ownership 14 | BuildRequires: qubes-core-agent 15 | %endif 16 | Requires: usbutils 17 | 18 | Source0: %{name}-%{version}.tar.gz 19 | 20 | %description 21 | USBIP wrapper to run it over Qubes RPC connection 22 | 23 | %prep 24 | %setup -q 25 | 26 | %install 27 | make install-vm DESTDIR=${RPM_BUILD_ROOT} 28 | 29 | %files 30 | #%%doc 31 | /etc/qubes-rpc/qubes.USB 32 | /etc/qubes-rpc/qubes.USBAttach 33 | /etc/qubes-rpc/qubes.USBDetach 34 | /etc/qubes/suspend-pre.d/usb-detach-all.sh 35 | /usr/lib/qubes/usb-import 36 | /usr/lib/qubes/usb-export 37 | /usr/lib/qubes/usb-detach-all 38 | /usr/lib/qubes/usb-reset 39 | /usr/lib/udev/rules.d/80-qubes-usb-reset.rules 40 | 41 | %changelog 42 | @CHANGELOG@ 43 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # The Qubes OS Project, http://www.qubes-os.org 5 | # 6 | # Copyright (C) 2016 Marek Marczykowski-Górecki 7 | # 8 | # 9 | # This program is free software; you can redistribute it and/or 10 | # modify it under the terms of the GNU General Public License 11 | # as published by the Free Software Foundation; either version 2 12 | # of the License, or (at your option) any later version. 13 | # 14 | # This program is distributed in the hope that it will be useful, 15 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 16 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 17 | # GNU General Public License for more details. 18 | # 19 | # You should have received a copy of the GNU General Public License 20 | # along with this program; if not, write to the Free Software 21 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, 22 | # USA. 23 | # 24 | 25 | from setuptools import setup 26 | import sys 27 | 28 | if sys.version_info < (3,): 29 | # don't install core3 extension 30 | setup( 31 | name='qubesusbproxy', 32 | version=open('version').read().strip(), 33 | packages=['qubesusbproxy'], 34 | entry_points={ 35 | 'qubes.tests.extra.for_template': 36 | 'usbproxy = qubesusbproxy.tests:list_tests', 37 | }, 38 | ) 39 | else: 40 | # install tests and core3 extension 41 | setup( 42 | name='qubesusbproxy', 43 | version=open('version').read().strip(), 44 | packages=['qubesusbproxy'], 45 | entry_points={ 46 | 'qubes.tests.extra.for_template': 47 | 'usbproxy = qubesusbproxy.tests:list_tests', 48 | 'qubes.tests.extra': 49 | 'usbproxy = qubesusbproxy.tests:list_unit_tests', 50 | 'qubes.ext': 51 | 'usbproxy = qubesusbproxy.core3ext:USBDeviceExtension', 52 | 'qubes.devices': 53 | 'usb = qubesusbproxy.core3ext:USBDevice', 54 | }, install_requires=['lxml'] 55 | ) 56 | -------------------------------------------------------------------------------- /src/80-qubes-usb-reset.rules: -------------------------------------------------------------------------------- 1 | # Nitrokey 3 Bootloader, requires reset on attach 2 | # See https://github.com/QubesOS/qubes-issues/issues/8953 3 | SUBSYSTEM=="usb", ENV{ID_VENDOR_ID}=="20a0", ENV{ID_MODEL_ID}=="42dd", ENV{QUBES_USB_RESET}="1" 4 | -------------------------------------------------------------------------------- /src/usb-detach-all: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Detach all devices before suspend 4 | 5 | for dev in /sys/bus/usb/devices/*/usbip_sockfd; do 6 | if [ -w "$dev" ]; then 7 | echo -1 > "$dev" 8 | fi 9 | done 10 | -------------------------------------------------------------------------------- /src/usb-export: -------------------------------------------------------------------------------- 1 | #!/bin/bash -- 2 | set -euo pipefail 3 | 4 | SYS_USB_DEVICES=/sys/bus/usb/devices 5 | SYS_USBIP_HOST=/sys/bus/usb/drivers/usbip-host 6 | 7 | # From /usr/include/linux/usbip.h 8 | SDEV_ST_AVAILABLE=1 9 | SDEV_ST_USED=2 10 | SDEV_ST_ERROR=3 11 | 12 | usage () { 13 | echo "$0 device" 14 | } 15 | 16 | if [ "$#" -lt 1 ]; then 17 | usage 18 | exit 1 19 | fi 20 | 21 | find_by_bus_dev () { 22 | local busnum="$1" 23 | local devnum="$2" 24 | local tmp_busnum tmp_devnum 25 | for devpath in "$SYS_USB_DEVICES"/*; do 26 | if [ ! -e "$devpath/busnum" ] && [ ! -e $devpath/devnum ]; then 27 | # skip individual interfaces etc 28 | continue 29 | fi 30 | read -r tmp_busnum < "$devpath/busnum" 31 | read -r tmp_devnum < "$devpath/devnum" 32 | if [ "$busnum" -eq "$tmp_busnum" ] && [ "$devnum" -eq "$tmp_devnum" ]; then 33 | return 34 | fi 35 | done 36 | echo "No matching device found ($bus, $dev)" >&2 37 | exit 1 38 | } 39 | 40 | # Resolve device name to sysfs path 41 | resolve_device () { 42 | device="$1" 43 | local lsusb_output_array IFS=$'\n' lsusb_output 44 | # handle different device formats 45 | case $device in 46 | 0x*.0x*) 47 | device=${device//./:} 48 | # make sure there is only one matching device 49 | # Example: Bus 003 Device 002: ID 05e3:0608 Genesys Logic, Inc. Hub 50 | set -f # suppress globbing 51 | lsusb_output=$(lsusb -d "$device") 52 | lsusb_output_array=($lsusb_output) 53 | set +f 54 | if [ "${#lsusb_output_array[@]}" -ne 1 ]; then 55 | echo "Multiple or no devices matching $device, aborting!" >&2 56 | exit 1 57 | fi 58 | bus=$(echo "$lsusb_output" | cut -d ' ' -f 2) 59 | dev=$(echo "$lsusb_output" | cut -d ' ' -f 4 | tr -d :) 60 | find_by_bus_dev "$bus" "$dev" 61 | ;; 62 | *-*) 63 | # a single device, but NOT a specific interface 64 | case $device in 65 | *:*) 66 | echo "You cannot export a specific device interface!" >&2 67 | exit 1 68 | ;; 69 | esac 70 | if ! [ -d "$SYS_USB_DEVICES/$device" ]; then 71 | echo "No such device: $device" >&2 72 | exit 1 73 | fi 74 | devpath="$SYS_USB_DEVICES/$device" 75 | ;; 76 | *) 77 | echo "Invalid device format: $device" >&2 78 | exit 1 79 | ;; 80 | esac 81 | } 82 | 83 | resolve_device "$1" 84 | if [ -z "$devpath" ]; then 85 | exit 1 86 | fi 87 | 88 | busid=${devpath##*/} 89 | pidfile="/var/run/qubes/usb-export-$busid.pid" 90 | 91 | modprobe usbip-host 92 | 93 | # Request that both IN and OUT be handled on a single (stdin) socket 94 | kill -USR1 "$QREXEC_AGENT_PID" || exit 1 95 | 96 | attach_to_usbip=true 97 | # Unbind the device from the driver 98 | if [ -d "$devpath/driver" ]; then 99 | old_driver=$(readlink -f "$devpath/driver") 100 | if [ "$old_driver" != "$SYS_USBIP_HOST" ]; then 101 | printf %s "$busid" > "$devpath/driver/unbind" || exit 1 102 | else 103 | attach_to_usbip= 104 | fi 105 | fi 106 | 107 | # Bind to the usbip-host driver 108 | printf 'add %s' "$busid" > "$SYS_USBIP_HOST/match_busid" || exit 1 109 | if [ -n "$attach_to_usbip" ]; then 110 | echo "$busid" > "$SYS_USBIP_HOST/bind" || exit 1 111 | 112 | # optionally reset the device to clear any state from previous driver 113 | reset_on_attach=$(udevadm info --query=property \ 114 | --value --property=QUBES_USB_RESET --path="$devpath") 115 | if [ -f /run/qubes-service/usb-reset-on-attach ]; then 116 | reset_on_attach=1 117 | fi 118 | if [ -n "$reset_on_attach" ]; then 119 | /usr/lib/qubes/usb-reset "$devpath" 120 | fi 121 | fi 122 | 123 | # One more safety check - make sure the device is available 124 | read status < "$devpath/usbip_status" 125 | if [ "$status" -ne "$SDEV_ST_AVAILABLE" ]; then 126 | printf 'Device %s not available!\n' "$devpath" >&2 127 | exit 1 128 | fi 129 | 130 | # Allow the device. 131 | if command -v usbguard > /dev/null; then 132 | usbguard allow-device "via-port \"$busid\"" || : 133 | fi 134 | 135 | read -r busnum < "$devpath/busnum" 136 | read -r devnum < "$devpath/devnum" 137 | devid=$(( busnum << 16 | devnum )) 138 | read -r speed < "$devpath/speed" 139 | 140 | # Send device details to the other end (usb-import script) 141 | printf '%s %s\n' "$devid" "$speed" >&0 142 | 143 | echo 0 > "$devpath/usbip_sockfd" || exit 1 144 | exec < /dev/null 145 | 146 | echo "$$" > "$pidfile" 147 | safe_busid=${busid//:/_} 148 | safe_busid=${safe_busid//./_} 149 | 150 | cleanup() { 151 | qubesdb-rm \ 152 | /qubes-usb-devices/${safe_busid}/connected-to \ 153 | /qubes-usb-devices/${safe_busid}/x-pid \ 154 | qubesdb-write /qubes-usb-devices '' 155 | exit 156 | } 157 | trap "cleanup" EXIT TERM 158 | qubesdb-write \ 159 | "/qubes-usb-devices/${safe_busid}/connected-to" "${QREXEC_REMOTE_DOMAIN%-dm}" \ 160 | "/qubes-usb-devices/${safe_busid}/x-pid" "$$" \ 161 | /qubes-usb-devices '' 162 | 163 | # FIXME this is racy as hell! 164 | while sleep 1; do 165 | # wait while device is "used" 166 | read -r status < "$devpath/usbip_status" 167 | if [ "$status" -ne "$SDEV_ST_USED" ]; then break; fi 168 | done 169 | # cleanup will be called automatically 170 | -------------------------------------------------------------------------------- /src/usb-import: -------------------------------------------------------------------------------- 1 | #!/bin/sh -- 2 | 3 | set -eu 4 | if command -v modprobe >/dev/null; then modprobe vhci-hcd; fi 5 | 6 | DEVPATH="/sys/devices/platform/vhci_hcd" 7 | if [ -d "${DEVPATH}.0" ]; then 8 | DEVPATH="${DEVPATH}.0" 9 | fi 10 | 11 | # From /usr/include/linux/usbip.h 12 | VDEV_ST_NULL=4 13 | VDEV_ST_NOTASSIGNED=5 14 | VDEV_ST_USED=6 15 | VDEV_ST_ERROR=7 16 | 17 | usage() { 18 | echo "$0 statefile" 19 | } 20 | 21 | ERROR() { 22 | ( 23 | echo "$* " 24 | echo "VM: \"`hostname`\" File: \"$0\" " 25 | echo "Version Control: " 26 | echo -n "https://github.com/QubesOS/qubes-app-linux-usb-proxy" 27 | echo "/blob/master/src/usb-import" 28 | ) >&2; 29 | exit 1 30 | } 31 | 32 | if [ "$#" -lt 1 ]; then 33 | usage 34 | exit 1 35 | fi 36 | 37 | statefile="$1" 38 | 39 | if [ -n "$SERVICE_ATTACH_PID" ]; then 40 | # use stderr of qubes.USBAttach service call 41 | # otherwise it would be redirected to /dev/null 42 | # (see comment in qubes.USBAttach) 43 | exec 2>"/proc/$SERVICE_ATTACH_PID/fd/2" 44 | fi 45 | 46 | # based on linux/tools/usb/usbip/libsrc/vhci_driver.c 47 | find_port() { 48 | # "hs" for high-speed and "ss" for super-speed 49 | requested_hub="$1" 50 | old_header= 51 | while read hub port sta spd bus dev socket local_busid extra; do 52 | if [ "$hub" = "port" ] || [ "$hub" = "prt" ]; then 53 | # old header: 54 | # port sta spd bus dev socket local_busid 55 | echo "kernel < 4.13 no longer supported" >&2 56 | exit 1 57 | elif [ "$hub" = "hub" ]; then 58 | # new header 59 | # hub port sta spd bus dev socket local_busid 60 | continue 61 | elif [ -n "$old_header" ] && [ "$port" -eq $VDEV_ST_NULL ]; then 62 | # port column in old header 63 | echo "kernel < 4.13 no longer supported" >&2 64 | exit 1 65 | elif [ -z "$old_header" ] && [ "$hub" = "$requested_hub" ] && [ "$sta" -eq $VDEV_ST_NULL ]; then 66 | echo "$port" 67 | return 0 68 | fi 69 | done < "$DEVPATH/status" 70 | ERROR "No unused port found!" 71 | } 72 | 73 | attach() { 74 | local port="$1" 75 | local remote_devid="$2" 76 | local speed="$3" 77 | # port sockfd devid speed 78 | printf "%s %u %u %u" "$port" "0" "$remote_devid" "$speed" > $DEVPATH/attach 79 | } 80 | 81 | wait_for_attached() { 82 | local port="$1" 83 | local local_busid="0-0" 84 | local timeout=25 85 | while [ ! -e "/sys/bus/usb/devices/$local_busid" ]; do 86 | sleep 0.2 87 | if ! port_status=$(grep -- "^\\(hs\\|ss\\)\\? *$port" "$DEVPATH/status"); then 88 | local status="$?" 89 | if [[ "$status" -gt 1 ]]; then exit "$status"; fi 90 | fi 91 | local_busid=${port_status##* } 92 | timeout=$(( timeout - 1 )) 93 | if [ "$timeout" -le 0 ]; then 94 | echo "$port" > $DEVPATH/detach 95 | ERROR "Attach timeout, check kernel log for details." 96 | fi 97 | done 98 | [ -f "/usr/bin/udevadm" ] && udevadm settle 99 | } 100 | 101 | wait_for_detached() { 102 | local port="$1" 103 | local local_busid 104 | local port_status 105 | if ! port_status=$(grep -- "^\\(hs\\|ss\\)\\? *$port" "$DEVPATH/status"); then 106 | local status="$?" 107 | if [[ "$status" -gt 1 ]]; then exit "$status"; fi 108 | fi 109 | local_busid=${port_status##* } 110 | if [ -z "$local_busid" ]; then 111 | return 112 | fi 113 | while [ -e "/sys/bus/usb/devices/$local_busid" ]; do 114 | sleep 1 115 | done 116 | } 117 | 118 | 119 | # negotiate parameters (field 'extra' reserved for future use) 120 | read untrusted_devid untrusted_speed untrusted_extra 121 | 122 | # check for unexpected EOF 123 | if [ -z "$untrusted_devid" ] && [ -z "$untrusted_speed" ]; then 124 | echo "No device info received, connection failed, check backend side for details" >&2 125 | exit 1 126 | fi 127 | 128 | case "$untrusted_speed" in 129 | 1.5) speed=1 ;; # Low Speed 130 | 12) speed=2 ;; # Full speed 131 | 480) speed=3 ;; # High Speed 132 | 53.3-480) speed=4 ;; # Wireless 133 | 5000) speed=5 ;; # Super Speed 134 | 10000) speed=5 ;; # Super Speed Plus (USB 3.1); Announce as USB 3.0 until USBIP get support 135 | *) ERROR "Invalid speed \"$untrusted_speed\" received." \ 136 | "Expected \"1.5\", \"12\", \"480\", \"53.3-480\", \"5000\", \"10000\". " \ 137 | "If the remote side sent nothing, this could mean "\ 138 | " - the device is invalid or unplugged" \ 139 | " - the VM crashed" \ 140 | " - qubes-usb-proxy is not installed" \ 141 | " - ...";; 142 | esac 143 | # 32bit integer 144 | if [ "$untrusted_devid" -ge 0 -a "$untrusted_devid" -lt 4294967296 ]; then 145 | devid="$untrusted_devid" 146 | else 147 | ERROR "Invalid devid \"$untrusted_devid\"." \ 148 | "Expected 0 <= devid < 4294967296." 149 | fi 150 | 151 | if [ "$speed" -ge 5 ]; then 152 | hub_type="ss" 153 | else 154 | hub_type="hs" 155 | fi 156 | port=$(find_port $hub_type) 157 | 158 | # Request that both IN and OUT be handled on a single (stdin) socket 159 | kill -USR1 "$QREXEC_AGENT_PID" || exit 1 160 | 161 | attach "$port" "$devid" "$speed" || exit 1 162 | 163 | echo "$port" >"$statefile" 164 | 165 | # wait for device really being attached 166 | wait_for_attached "$port" 167 | 168 | # notify qubes.USBAttach service about successful connection 169 | if [ -n "$SERVICE_ATTACH_PID" ]; then 170 | kill -HUP $SERVICE_ATTACH_PID 171 | fi 172 | 173 | # close stdin/out so the kernel is the only one with the socket reference 174 | exec < /dev/null >/dev/null 2>&1 175 | 176 | # do not end the process until device is detached, to not close the qrexec connection 177 | wait_for_detached "$port" 178 | -------------------------------------------------------------------------------- /src/usb-reset: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | import sys 4 | import os 5 | import fcntl 6 | from pathlib import Path 7 | 8 | # from /usr/include/linux/usbdevice_fs.h 9 | # _IO('U', 20) 10 | USBDEVFS_RESET = 0x5514 11 | 12 | def main(): 13 | if len(sys.argv) != 2: 14 | print("Usage: usb-reset sysfs-devpath", file=sys.stderr()) 15 | exit(2) 16 | devpath = sys.argv[1] 17 | uevent = (Path(devpath) / "uevent").read_text() 18 | devname = [line.partition("=")[2] 19 | for line in uevent.splitlines() 20 | if line.startswith("DEVNAME=")][0] 21 | with (Path("/dev") / devname).open("w") as dev_f: 22 | fcntl.ioctl(dev_f, USBDEVFS_RESET, 0) 23 | 24 | if __name__ == "__main__": 25 | main() 26 | -------------------------------------------------------------------------------- /version: -------------------------------------------------------------------------------- 1 | 4.3.0 2 | --------------------------------------------------------------------------------