├── .gitignore ├── README.md ├── _qemu_usb_dm_config.yml ├── qemu_usb_device_manager ├── __init__.py ├── client.py ├── constants.py ├── main.py ├── monitor.py └── utils.py ├── run.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.gitignore.io/api/python 2 | 3 | ### Python ### 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | config.json 9 | config.yml 10 | config.yaml 11 | 12 | # C extensions 13 | *.so 14 | 15 | # Distribution / packaging 16 | .Python 17 | env/ 18 | build/ 19 | develop-eggs/ 20 | dist/ 21 | downloads/ 22 | eggs/ 23 | .eggs/ 24 | lib/ 25 | lib64/ 26 | parts/ 27 | sdist/ 28 | var/ 29 | *.egg-info/ 30 | .installed.cfg 31 | *.egg 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *,cover 52 | .hypothesis/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | 62 | # Flask stuff: 63 | instance/ 64 | .webassets-cache 65 | 66 | # Scrapy stuff: 67 | .scrapy 68 | 69 | # Sphinx documentation 70 | docs/_build/ 71 | 72 | # PyBuilder 73 | target/ 74 | 75 | # IPython Notebook 76 | .ipynb_checkpoints 77 | 78 | # pyenv 79 | .python-version 80 | 81 | # celery beat schedule file 82 | celerybeat-schedule 83 | 84 | # dotenv 85 | .env 86 | 87 | # virtualenv 88 | venv/ 89 | ENV/ 90 | 91 | # Spyder project settings 92 | .spyderproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # config 98 | qemu_usb_dm_config.* 99 | config.* 100 | *.zip 101 | 102 | # sublime 103 | *.sublime-* -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # QEMU USB Device Manager 2 | 3 | QEMU USB Device Manager is a limited wrapper of the QEMU monitor for USB management. 4 | 5 | **What is the purpose?** 6 | The purpose of this project is to create a safe and stable way of adding and removing USB devices to and fro a host and virtual machine. The USB devices are passed through to the virtual machine, essentially, making them native input. 7 | 8 | **How does this compare to ...?** 9 | This project's method is a software-alternative to a [KVM switch](https://en.wikipedia.org/wiki/KVM_switch). It also differs from network KVM solutions, such as [Synergy](https://symless.com/synergy), as the USB devices are passed through to the virtual machine and, therefore, input is not subject to network latency. 10 | 11 | **Why not use the built-in USB device manager in a VM manager?** 12 | 1. Can add and remove USB devices from within the virtual machine. 13 | 2. Addition/removal can be ran from inside of a script. 14 | 3. Can be tied to a hotkey (by your WM/DE) for addition/removal in a single keypress. 15 | 16 | ## Requirements 17 | * Python 3.4 or higher 18 | * QEMU 2.10.0 or higher 19 | 20 | ## Setup 21 | **Host machine setup** 22 | You must include the monitor flag in your QEMU command, or the equivalent in your Libvirt config. 23 | ``` 24 | -monitor telnet:0.0.0.0:7101,server,nowait,nodelay 25 | ``` 26 | 27 | **Installation method** 28 | *Installing with escalated privilege (e.g. sudo) creates a quick access executable `usb_dm` for convenience. Otherwise, you will need to find run.py of the qemu-usb-device-manager directory in your Python's site-packages.* 29 | ```sh 30 | # Install with pip. Recommended: use 'sudo' in your Linux distribution. 31 | pip install https://github.com/PassthroughPOST/qemu-usb-device-manager/archive/master.zip --upgrade 32 | 33 | # Create your configuration file. 34 | 35 | # Start program in interactive mode. 36 | usb_dm 37 | ``` 38 | 39 | **No installation method** 40 | 1. Download and extract a release zip or the master branch zip. 41 | 2. Create your configuration file. 42 | 3. Execute `run.py` (instead of usb_dm). 43 | 44 | ## Configuration 45 | An example configuration is [located here](https://github.com/PassthroughPOST/qemu-usb-device-manager/blob/master/_qemu_usb_dm_config.yml). 46 | 47 | Unless specified by the --config flag, this program will search for `qemu_usb_dm_config.yml` in the following order: 48 | 1. Current directory. (pwd) 49 | 2. Home directory. (~) 50 | 3. Project directory. 51 | 4. `QEMU_USB_DEVICE_MANAGER_CONFIG` environment variable. 52 | 53 | 54 | ## Arguments 55 | ``` 56 | --name, --set, -n, -s | set virtual machine 57 | --command, -c | run command 58 | --config, --conf | specify configuration file path 59 | --log | specify log file path 60 | ``` 61 | 62 | ## Commands 63 | ``` 64 | - help | List commands 65 | - version | Display version 66 | - exit | Exit limited monitor 67 | - wait [seconds] | Wait for an amount of time 68 | - reload | Reload config file 69 | - update | Update config file from 'configuration-url' 70 | - monitor | Show monitor information 71 | - list | List USB devices connected to virtual machine 72 | - hostlist | List USB devices connected to host machine 73 | - set | Show available virtual machines 74 | - set [name] | Set active machine by name 75 | - add | Add all USB devices 76 | - add [id] | Add USB device by id 77 | - add [name] | Add USB device by specified name 78 | - remove | Remove all USB devices 79 | - remove [id] | Remove USB device by id 80 | - remove [name] | Remove USB device by specified name 81 | ``` 82 | 83 | ## Examples 84 | ```sh 85 | # Note: 'usb_dm' is only available when installed using pip with escalated privileges. 86 | # Otherwise, inside of the project's path 'run.py' must be substituted for 'usb_dm'. 87 | 88 | # Start in interactive mode 89 | usb_dm 90 | 91 | # Add devices to virtual machine vm-1 92 | usb_dm -n vm-1 -c add 93 | 94 | # Remove devices from virtual machine vm-1 95 | usb_dm -n vm-1 -c remove 96 | 97 | # Add mouse and keyboard to vm-1 98 | usb_dm -n vm-1 -c "add mouse" "add keyboard" 99 | 100 | # Add device by vendor and product id 101 | usb_dm -n vm-1 -c "add 046d:c52b" 102 | ``` 103 | -------------------------------------------------------------------------------- /_qemu_usb_dm_config.yml: -------------------------------------------------------------------------------- 1 | --- 2 | configuration-url: 'https://example.com/path_to_shared_config.yml' # Optional 3 | 4 | 5 | host-machine: 6 | hostname: pc 7 | 8 | 9 | usb-devices: 10 | # An example device. 11 | keyboard: 12 | id: '1b1c:1b09' 13 | 14 | # Devices can be named anything but names must be different. 15 | mouse: 16 | id: '046d:c52b' 17 | 18 | # A device can be fixed to a VM using the 'add only' action. 19 | # The device will never be automatically removed in bulk remove situations. 20 | # Device can still be removed if . 21 | microphone: 22 | id: '17a0:0310' 23 | action: 'add only' 24 | 25 | 26 | # A device can be ignored using the 'ignore' action. 27 | external-harddrive: 28 | id: '1d33:d623' 29 | action: 'ignore' 30 | 31 | 32 | virtual-machines: 33 | # Connect on port 7101, IP address will be determined based on machine. 34 | # Current virtual machine can be automatically set by including its hostname. 35 | windows-vm-1: 36 | monitor: ':7101' 37 | hostname: 'win-vm' 38 | 39 | # Connect using an IP address on port 7101. 40 | windows-vm-2: 41 | monitor: '192.168.1.5:7101' 42 | 43 | # Connect using the host's hostname on port 7103. 44 | windows-vm-2: 45 | monitor: 'pc:7103' 46 | -------------------------------------------------------------------------------- /qemu_usb_device_manager/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from .constants import VERSION 3 | from .main import main as run 4 | from .monitor import Monitor 5 | from .client import Client 6 | 7 | 8 | __all__ = ["VERSION", "run", "Monitor", "Client"] -------------------------------------------------------------------------------- /qemu_usb_device_manager/client.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | import yaml 4 | from sys import exit 5 | from socket import gethostname 6 | from time import sleep 7 | from . import constants 8 | from .monitor import Monitor 9 | from .utils import get_gateway, download_string 10 | 11 | 12 | 13 | class Client(object): 14 | """ 15 | Client that interacts with monitor on a higher level. 16 | """ 17 | required_keys = ("usb-devices", "host-machine", "virtual-machines") 18 | actions = { 19 | "ignore": ("ignore", "ignored", "disable", "disabled"), 20 | "add only": ("add only", "addonly", "add_only", "add-only"), 21 | "remove only": ("remove only", "removeonly", "remove_only", "remove-only") 22 | } 23 | 24 | 25 | def __init__(self, machine_name, config_filepath, log_filepath=None): 26 | """ 27 | Load configuration from yaml file. 28 | 29 | Args: 30 | config_filepath (str): Configuration file path 31 | machine_name (str): Virtual machine name 32 | """ 33 | # Initiate logging 34 | if log_filepath: 35 | logging.basicConfig(filename=log_filepath) 36 | 37 | self.config_filepath = config_filepath 38 | self.machine_name = machine_name 39 | self.load_config() 40 | print(constants.CLIENT_WELCOME) 41 | 42 | 43 | def load_config(self): 44 | """ 45 | Load configuration file. 46 | ** This method needs to be split up and cleaned. 47 | """ 48 | try: 49 | with open(self.config_filepath) as f: 50 | self.config = yaml.load(f, Loader=yaml.FullLoader) 51 | except Exception as exc: 52 | logging.exception(exc) 53 | return False 54 | 55 | # Verify required keys are present 56 | rewrite_required = False 57 | 58 | # Find missing elements 59 | for key in self.required_keys: 60 | if not key in self.config.keys(): 61 | self.config[key] = {} 62 | rewrite_required = True 63 | print(constants.CONFIG_MISSING_ELEMENT % key) 64 | 65 | # Rewrite configuration with required elements 66 | if rewrite_required: 67 | print(constants.CONFIG_REWRITE_MESSAGE) 68 | try: 69 | with open(self.config_filepath, "w") as f: 70 | yaml.dump(self.config, f) 71 | except Exception as exc: 72 | print(constants.CONFIG_CANNOT_REWRITE) 73 | logging.exception(exc) 74 | return False 75 | return self.load_config() 76 | 77 | self.configuration_url = self.config.get("configuration-url", None) 78 | self.host_config = self.config["host-machine"] 79 | 80 | # Set machine by hostname if not specified 81 | if not self.machine_name and not self.is_host_machine(): 82 | hostname = gethostname() 83 | for key, value in self.config["virtual-machines"].items(): 84 | if "hostname" in value and value["hostname"] == hostname: 85 | self.machine_name = key 86 | 87 | # Get useful info from config 88 | self.usb_devices_full = { 89 | k: v for k, v in self.config["usb-devices"].items() 90 | if v.get("action") not in self.actions["ignore"] 91 | } 92 | 93 | # VM 94 | self.vm_config = self.config["virtual-machines"].get(self.machine_name) 95 | self.vm_names = list(self.config["virtual-machines"].keys()) 96 | self.usb_devices = list(self.usb_devices_full.values()) 97 | 98 | # No machine config? No monitor inside machine config? Goodbye. 99 | if not (self.vm_config and "monitor" in self.vm_config): 100 | return True 101 | 102 | # Host name for monitor 103 | monitor_host = self.vm_config["monitor"] 104 | 105 | # If monitor_host starts with a colon, we should guess which IP to use 106 | # when it's not, Monitor IP:Port is probably specified by user 107 | if monitor_host[0] != ":": 108 | self.monitor = Monitor(monitor_host) 109 | return True 110 | 111 | # Did user define their own monitor host? 112 | # User can set 'ip-address' to '-' to automatically determine ip address 113 | default_ip = None 114 | if self.host_config.get("ip-address", "-") == "-": 115 | # Are we the host machine? 116 | if self.is_host_machine(): 117 | default_ip = "127.0.0.1" 118 | 119 | # Or are we the virtual machine? 120 | else: 121 | default_ip = get_gateway() 122 | 123 | # Remember that "monitor_host" is just a port prefixed with a colon 124 | host = self.host_config.get("ip-address", default_ip) + monitor_host 125 | 126 | # Create monitor 127 | self.monitor = Monitor(host) 128 | return True 129 | 130 | 131 | def is_host_machine(self): 132 | """ 133 | Determine if current machine is the host. 134 | 135 | Returns: 136 | bool 137 | """ 138 | return self.host_config.get("hostname", "") == gethostname() 139 | 140 | 141 | def monitor_command(self, func): 142 | """ 143 | The monitor command process: Connect, run, disconnect. 144 | 145 | Args: 146 | func (function): Callback function 147 | """ 148 | if not self.monitor.connect(): 149 | print(constants.MONITOR_CANNOT_CONNECT) 150 | return 151 | result = func(self.monitor) 152 | self.monitor.disconnect() 153 | return result 154 | 155 | 156 | def device_names_to_ids(self, devices): 157 | """ 158 | Create list of devices by looping through 'devices' values and trying to 159 | find the keys in 'usb_devices_full'. 160 | 161 | Ignore if not vendor and product id. 162 | 163 | Args: 164 | devices (list): List of devices 165 | """ 166 | result = [] 167 | host_devices = self.monitor_command(lambda m: m.host_usb_devices()) 168 | host_ids = [device["id"] for device in host_devices] 169 | 170 | for device in devices: 171 | # named device 172 | name = self.usb_devices_full.get(device) 173 | if name: 174 | id = name.get("id") 175 | 176 | # vendor and product id 177 | else: 178 | id = device if ":" in device else None 179 | 180 | # Be sure ids exist, otherwise the error message below is spat out 181 | # and VM performance seems to become crippled 182 | # qemu-system-x86_64: libusb_release_interface: -99 [OTHER] 183 | # libusb: error [release_interface] release interface failed, error -1 errno 22 184 | if id in host_ids: 185 | result.append(id) 186 | 187 | return result 188 | 189 | 190 | def parse_command(self, text): 191 | """ 192 | Split command and args. 193 | 194 | Args: 195 | text (str): Command 196 | """ 197 | text = text.split(" ", 1) 198 | return (text[0], text[1].split(" ") if len(text) > 1 else None) 199 | 200 | 201 | def run_command(self, text): 202 | """ 203 | Run command for monitor 204 | 205 | Args: 206 | text (str): Command 207 | """ 208 | command, args = self.parse_command(text) 209 | 210 | # List commands 211 | if command == "help": 212 | self.command_help(args) 213 | 214 | # Exit 215 | elif command == "exit" or command == "quit": 216 | exit(0) 217 | 218 | # Version 219 | elif command == "version": 220 | self.command_version(args) 221 | 222 | # Wait 223 | elif command == "wait" or command == "sleep": 224 | if args: sleep(float(args[0])) 225 | 226 | # Reload configuration file 227 | elif command == "reload": 228 | self.command_reload(args) 229 | 230 | # Update configuration file from 'configuration-url' 231 | elif command == "update": 232 | self.command_update(args) 233 | self.command_reload([]) 234 | 235 | # Show monitor information 236 | elif command == "monitor": 237 | self.command_monitor(args) 238 | 239 | # Set active machine 240 | elif command == "set": 241 | self.command_set(args) 242 | 243 | # ** All commands below require that monitor is set and online ** 244 | elif not self.vm_config: 245 | print(constants.CLIENT_NO_VM_SET) 246 | 247 | # List USB devices 248 | elif command == "list": 249 | self.command_list(args) 250 | 251 | # List USB devices connected to host 252 | elif command == "hostlist" or command == "listhost": 253 | self.command_hostlist(args) 254 | 255 | # Add USB device 256 | elif command == "add": 257 | self.command_add(args) 258 | 259 | # Remove USB devices 260 | elif command == "remove" or command == "rem" or command == "del": 261 | self.command_remove(args) 262 | 263 | else: 264 | print(constants.CLIENT_UNKNOWN_COMMAND) 265 | 266 | 267 | def command_info(self, args): 268 | """ 269 | Print info about current session. 270 | 271 | Args: 272 | args (list): List arguments 273 | """ 274 | print(constants.CLIENT_INFO % { 275 | "VERSION": constants.VERSION, 276 | "CONFIG_FILEPATH": self.config_filepath, 277 | "HOME_DIR": os.path.expanduser("~"), 278 | "BASE_DIR": os.path.dirname(__file__), 279 | "CURRENT_DIR": os.getcwd() 280 | }) 281 | 282 | 283 | def command_version(self, args): 284 | """ 285 | Print version. 286 | 287 | Args: 288 | args (list): List arguments 289 | """ 290 | print(constants.VERSION) 291 | 292 | 293 | def command_help(self, args): 294 | """ 295 | List commands. 296 | 297 | Args: 298 | args (list): List arguments 299 | """ 300 | print(constants.CLIENT_HELP) 301 | 302 | 303 | def command_monitor(self, args): 304 | """ 305 | Show monitor information. 306 | 307 | Args: 308 | args (list): List arguments 309 | """ 310 | try: 311 | print("Host:", self.monitor.host) 312 | except: 313 | print(constants.MONITOR_NOT_SET) 314 | return 315 | 316 | 317 | def command_update(self, args): 318 | """ 319 | Download url set in 'configuration-url' and attempt to parse with YAML. 320 | If the new config is valid YAML then replace current config. 321 | 322 | Args: 323 | args (list): List arguments 324 | """ 325 | old_url = None 326 | if args: 327 | old_url = self.configuration_url 328 | self.configuration_url = args[0] 329 | 330 | if not self.configuration_url: 331 | print(constants.CONFIG_URL_NOT_SET) 332 | return 333 | 334 | try: 335 | # Download new config 336 | new_config = download_string(self.configuration_url) 337 | 338 | # Parse new config 339 | new_config = yaml.safe_load(new_config) 340 | 341 | # Attempt parsing and see if required keys are available 342 | for key in self.required_keys: 343 | new_config[key] 344 | 345 | # Overwrite old configuration 346 | with open(self.config_filepath, "w") as f: 347 | f.write(yaml.dump(new_config)) 348 | print(constants.CONFIG_UPDATED_FROM_URL) 349 | 350 | except Exception as exc: 351 | if old_url: 352 | self.configuration_url = old_url 353 | 354 | print(constants.CONFIG_CANNOT_LOAD_NEW) 355 | logging.exception(exc) 356 | 357 | 358 | def command_reload(self, args): 359 | """ 360 | Reload configuration. 361 | 362 | Args: 363 | args (list): List arguments 364 | """ 365 | if args: 366 | self.config_filepath = args[0] 367 | 368 | if self.load_config(): 369 | print(constants.CONFIG_RELOAD) 370 | else: 371 | print(constants.CONFIG_CANNOT_RELOAD) 372 | 373 | 374 | def command_set(self, args): 375 | """ 376 | Set active machine. 377 | 378 | Args: 379 | args (list): List arguments 380 | """ 381 | if args: 382 | # Backup old name, and reload new one 383 | old_name = self.machine_name 384 | self.machine_name = args[0] 385 | self.load_config() 386 | 387 | if not self.vm_config: 388 | print(constants.CLIENT_INVALID_VM) 389 | 390 | # Reload old config 391 | self.machine_name = old_name 392 | self.load_config() 393 | else: 394 | print(constants.CLIENT_SET_ACTIVE % self.machine_name) 395 | return # Return to not show available virtual machines 396 | 397 | # Show available virtual machines 398 | print(constants.CLIENT_CURRENT_VM % self.machine_name) 399 | print(constants.CLIENT_VMS) 400 | for name in self.vm_names: 401 | print("-", name, "[Active]" if name == self.machine_name else "") 402 | 403 | 404 | def command_list(self, args): 405 | """ 406 | List USB devices connected to virtual machine. 407 | 408 | Args: 409 | args (list): List arguments 410 | """ 411 | if not self.monitor.connect(): 412 | print(constants.MONITOR_CANNOT_CONNECT) 413 | return 414 | 415 | for device in self.monitor.usb_devices_more(): 416 | print(constants.CLIENT_VM_DEVICE % ( 417 | device.get("id", "Unknown "), 418 | device["device"], device["product"] 419 | )) 420 | 421 | self.monitor.disconnect() 422 | 423 | 424 | def command_hostlist(self, args): 425 | """ 426 | List USB devices connected to host. 427 | 428 | Args: 429 | args (list): List arguments 430 | """ 431 | if not self.monitor.connect(): 432 | print(constants.MONITOR_CANNOT_CONNECT) 433 | return 434 | 435 | # Display host usb devices 436 | for device in self.monitor.host_usb_devices_more(): 437 | print(constants.CLIENT_HOST_DEVICE % ( 438 | device.get("id", "Unknown"), device.get("product", "Unknown"), 439 | constants.CLIENT_DEVICE_CONNECTED if "device" in device else "" 440 | )) 441 | 442 | self.monitor.disconnect() 443 | 444 | 445 | def command_add(self, args): 446 | """ 447 | Add USB devices. 448 | 449 | Args: 450 | args (list): List arguments 451 | """ 452 | # Add all USB devices 453 | if not args: 454 | args = [ # Only add devices without the action of "remove only" 455 | device["id"] for device in self.usb_devices 456 | if device.get("action") not in self.actions["remove only"] 457 | ] 458 | else: 459 | args = self.device_names_to_ids(args) 460 | 461 | # Add USB device 462 | if self.monitor_command(lambda m: m.add_usb(args)): 463 | print(constants.CLIENT_ADDED % args) 464 | else: 465 | print(constants.CLIENT_CANNOT_ADD % args) 466 | 467 | 468 | def command_remove(self, args): 469 | """ 470 | Remove USB devices. 471 | 472 | Args: 473 | args (list): List arguments 474 | """ 475 | # Remove all USB devices 476 | if not args: 477 | args = [ # Only add devices without the action of "add only" 478 | device["id"] for device in self.usb_devices 479 | if device.get("action") not in self.actions["add only"] 480 | ] 481 | else: 482 | args = self.device_names_to_ids(args) 483 | 484 | # Remove USB device 485 | if self.monitor_command(lambda m: m.remove_usb(args)): 486 | print(constants.CLIENT_REMOVED % args) 487 | else: 488 | print(constants.CLIENT_CANNOT_REMOVE % args) -------------------------------------------------------------------------------- /qemu_usb_device_manager/constants.py: -------------------------------------------------------------------------------- 1 | VERSION = "1.1" # Remember to change version in setup.py too! 2 | CONFIG_NAME_SHORT = "config" 3 | CONFIG_NAME_LONG = "qemu_usb_dm_config" 4 | 5 | # Monitor 6 | MONITOR_NOT_SET = "No monitor set." 7 | MONITOR_IN_USE = "Monitor is already in use." 8 | MONITOR_CANNOT_CONNECT = "Could not connect to monitor." 9 | 10 | 11 | # Client 12 | CLIENT_NO_VM_SET = "No virtual machine is set. Set one with the 'set' command." 13 | CLIENT_INVALID_VM = "Invalid virtual machine." 14 | CLIENT_SET_ACTIVE = "'%s' set as active virtual machine." 15 | CLIENT_CURRENT_VM = "Currently set Virtual Machine: %s" 16 | CLIENT_VMS = "Virtual Machines: " 17 | CLIENT_VM_DEVICE = "- ID: %s / Device: %s / %s" 18 | CLIENT_HOST_DEVICE = "- ID: %s / %s %s" 19 | CLIENT_DEVICE_CONNECTED = "[Connected]" 20 | CLIENT_UNKNOWN_COMMAND = "Unknown command. Type 'help' for a list of commands." 21 | CLIENT_ADDED = "Added device(s): %s" 22 | CLIENT_REMOVED = "Removed device(s): %s" 23 | CLIENT_CANNOT_ADD = "Could not add device(s): %s" 24 | CLIENT_CANNOT_REMOVE = "Could not remove device(s): %s" 25 | CLIENT_WELCOME = \ 26 | """ 27 | Limited QEMU Monitor Wrapper for USB management 28 | Type 'help' for a list of commands. 29 | """.strip() 30 | CLIENT_HELP = \ 31 | """ 32 | - help | List commands 33 | - version | Display version 34 | - exit | Exit limited monitor 35 | - wait [seconds] | Wait for an amount of time 36 | - reload | Reload config file 37 | - update | Update config file from 'configuration-url' 38 | - monitor | Show monitor information 39 | - list | List USB devices connected to virtual machine 40 | - hostlist | List USB devices connected to host machine 41 | - set | Show available virtual machines 42 | - set [name] | Set active machine by name 43 | - add | Add all USB devices 44 | - add [id] | Add USB device by id 45 | - add [name] | Add USB device by specified name 46 | - remove | Remove all USB devices 47 | - remove [id] | Remove USB device by id 48 | - remove [name] | Remove USB device by specified name 49 | """.strip() 50 | CLIENT_INFO = \ 51 | """ 52 | VERSION: %(VERSION)s 53 | CONFIG: %(CONFIG_FILEPATH)s 54 | CURRENT_DIR: %(CURRENT_DIR)s 55 | HOME_DIR: %(HOME_DIR)s 56 | BASE_DIR: %(BASE_DIR)s 57 | """.strip() 58 | 59 | 60 | # Config 61 | CONFIG_DOES_NOT_EXIST = "Configuration file (%s) does not exist." 62 | CONFIG_CANNOT_LOAD = "Cannot load configuration.\n%s" 63 | CONFIG_LOOKED_FOR = "Looked for '%s' in these directories:" 64 | CONFIG_CANNOT_LOAD_NEW = "Cannot load new configuration." 65 | CONFIG_CANNOT_RELOAD = "Could not reload configuration file." 66 | CONFIG_RELOAD = "Reloaded configuration file." 67 | CONFIG_MISSING_ELEMENT = "Element '%s' missing from config." 68 | CONFIG_CANNOT_REWRITE = "Cannot rewrite configuration." 69 | CONFIG_URL_NOT_SET = "No configuration url set." 70 | CONFIG_UPDATED_FROM_URL = "Updated configuration from url." 71 | CONFIG_REWRITE_MESSAGE = \ 72 | """ 73 | Adding required elements. Please modify them in your config. 74 | Reload by typing 'reload' after you are finished. 75 | """.strip() 76 | 77 | 78 | # Utils 79 | UTIL_GATEWAY_UNSUPPORTED = "get_gateway() is currently only supported on Windows." -------------------------------------------------------------------------------- /qemu_usb_device_manager/main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import os 3 | import sys 4 | from argparse import ArgumentParser 5 | from . import constants 6 | from .client import Client 7 | from .utils import directories, find_file 8 | 9 | 10 | def main(): 11 | """ 12 | Run QEMU USB Device Manager. 13 | """ 14 | directories_ = directories() 15 | 16 | # Arguments 17 | parser = ArgumentParser(description="Limited QEMU Monitor Wrapper for USB management") 18 | parser.add_argument("--name", "--set", "-n", "-s", help="Name of virtual machine") 19 | parser.add_argument("--command", "-c", help="Command", nargs="*") 20 | parser.add_argument("--config", "--conf", help="YAML config file location", nargs="?") 21 | parser.add_argument("--log", help="Log file location", nargs="?") 22 | args = parser.parse_args() 23 | 24 | # Configuration File 25 | config_filepath = None 26 | 27 | if args.config and os.path.isfile(args.config): 28 | config_filepath = args.config 29 | 30 | else: 31 | extensions = (".yml", ".yaml") 32 | 33 | # Attempt to find local config 34 | config_filepath = find_file( 35 | (directories_["CURRENT_DIR"],), 36 | constants.CONFIG_NAME_SHORT, 37 | extensions 38 | ) 39 | 40 | # Search in BASE_DIR and HOME_DIR now 41 | if not config_filepath: 42 | config_filepath = find_file( 43 | directories_.values(), 44 | constants.CONFIG_NAME_LONG, 45 | extensions 46 | ) 47 | 48 | # Environment variable? 49 | if not config_filepath: 50 | config_filepath = os.environ.get("QEMU_USB_DEVICE_MANAGER_CONFIG", None) 51 | 52 | if not (config_filepath and os.path.isfile(config_filepath)): 53 | print(constants.CONFIG_DOES_NOT_EXIST % config_filepath) 54 | print(constants.CONFIG_LOOKED_FOR % constants.CONFIG_NAME_LONG) 55 | for name, directory in directories_.items(): 56 | print("- %s: %s" % (name, directory)) 57 | 58 | # Some Windows installs don't give the user enough time to read the 59 | # error message before closing 60 | import time 61 | time.sleep(3) 62 | sys.exit(0) 63 | 64 | 65 | # Log File 66 | log_filepath = None 67 | 68 | log_env = os.environ.get("QEMU_USB_DEVICE_MANAGER_LOG") 69 | if log_env: 70 | log_filepath = log_env 71 | 72 | 73 | # Monitor Wrapper Client 74 | client = Client(args.name, config_filepath, args.log) 75 | 76 | 77 | # Loop over CLI commands when commands specified 78 | if args.command: 79 | for command in args.command: 80 | print(">" + command) 81 | client.run_command(command) 82 | 83 | 84 | # Otherwise, run forever 85 | else: 86 | while True: 87 | client.run_command(input(">")) 88 | 89 | 90 | if __name__ == "__main__": 91 | main() -------------------------------------------------------------------------------- /qemu_usb_device_manager/monitor.py: -------------------------------------------------------------------------------- 1 | from time import sleep 2 | from sys import stderr 3 | from telnetlib import Telnet 4 | from . import constants 5 | 6 | 7 | 8 | class Monitor(object): 9 | """ 10 | Monitor class is a very limited wrapper for the QEMU Monitor. 11 | It connects through telnet to control the virtual machine's monitor. 12 | """ 13 | 14 | def __init__(self, host): 15 | """ 16 | Initialize Monitor class. 17 | 18 | Args: 19 | host (str): IP address and Port of Telnet monitor 20 | """ 21 | host = host.split(":") 22 | port = int(host[1]) if host[1].isnumeric() else 23 23 | 24 | self.host = (host[0], port) 25 | self.is_connected = False 26 | 27 | 28 | def connect(self, retry=True, retry_wait=0.25, max_retries=5, _retries=0): 29 | """ 30 | Connect to Telnet monitor. 31 | 32 | Args: 33 | retry (bool, optional): Attempt retry if connection is not successful 34 | retry_wait (float, optional): Amount of time to wait for retrying 35 | max_retries (int, optional): Maximum amount of retries 36 | """ 37 | if self.is_connected: 38 | return 39 | 40 | try: 41 | self.telnet = Telnet(*self.host) 42 | 43 | if not "QEMU" in self.__read(True): 44 | if not retry or _retries >= max_retries: 45 | raise ConnectionAbortedError(constants.MONITOR_IN_USE) 46 | 47 | sleep(retry_wait) 48 | return self.connect(retry, max_retries, _retries + 1) 49 | else: 50 | self.is_connected = True 51 | except: 52 | self.is_connected = False 53 | 54 | return self.is_connected 55 | 56 | 57 | def disconnect(self): 58 | """ 59 | Close Telnet monitor socket. 60 | """ 61 | self.telnet.close() 62 | self.is_connected = False 63 | sleep(0.1) 64 | return not self.is_connected 65 | 66 | 67 | def __write(self, value): 68 | """ 69 | Write string to monitor. 70 | 71 | Args: 72 | value (str): Text to write to telnet monitor 73 | """ 74 | if not self.is_connected: 75 | return 76 | 77 | try: 78 | self.telnet.write(bytes(str(value + "\n").encode("utf-8"))) 79 | except BrokenPipeError: 80 | self.is_connected = False 81 | 82 | 83 | def __read(self, _force_read=False): 84 | """ 85 | Read from monitor. 86 | """ 87 | if not self.is_connected and not _force_read: 88 | return "" 89 | 90 | sleep(0.05) # Data has a delay 91 | try: 92 | return str(self.telnet.read_very_eager().decode("utf-8")) 93 | except BrokenPipeError: 94 | self.is_connected = False 95 | 96 | 97 | def add_usb(self, device): 98 | """ 99 | Add USB device by vendor:product id. 100 | Verify that device is not already added. 101 | 102 | Args: 103 | device (Union[str, list]): Device ID 104 | """ 105 | result = True 106 | 107 | # Single device 108 | if type(device) is str: 109 | if not self.id_is_connected(device): 110 | args = "usb-host,vendorid=0x%s,productid=0x%s,id=%s" % ( 111 | self.device_ids(device) 112 | ) 113 | self.__write("device_add " + args) 114 | else: 115 | return False 116 | 117 | # Multiple devices 118 | elif type(device) is list: 119 | devices = device 120 | for device in devices: 121 | if not self.add_usb(device): 122 | result = False 123 | 124 | return (not "could not" in self.__read()) if result else result 125 | 126 | 127 | def remove_usb(self, device): 128 | """ 129 | Remove USB device by vendor id. 130 | 131 | Args: 132 | device (Union[str, list]): Device ID 133 | """ 134 | 135 | # Single device 136 | if type(device) is str: 137 | # Prefer removing by user-supplied ID 138 | args = self.device_to_userid(device) 139 | if args is None: 140 | args = self.device_ids(device)[2] 141 | self.__write("device_del " + args) 142 | 143 | # Multiple devices 144 | elif type(device) is list: 145 | devices = device 146 | for device in devices: 147 | self.remove_usb(device) 148 | 149 | return not "could not" in self.__read() 150 | 151 | 152 | def device_ids(self, value): 153 | """ 154 | Split vendor id and product id. 155 | 156 | Args: 157 | value (str): device vendor and product id 158 | 159 | Returns: 160 | tuple: (vendor id, product id, cosmetic id) 161 | """ 162 | vendor_id, product_id = tuple(value.split(":")[-2:]) 163 | cosmetic_id = "device-%s-%s" % (vendor_id, product_id) 164 | return (vendor_id, product_id, cosmetic_id) 165 | 166 | 167 | def device_to_userid(self, value): 168 | """ 169 | Find user-supplied ID (if any) from vendor and product id 170 | 171 | Args: 172 | value (str): Vendor:Product ID 173 | Returns: 174 | User ID if found, otherwise it returns None 175 | """ 176 | data = self.usb_devices_more() 177 | 178 | if value.startswith("host:"): 179 | value = value[5:] 180 | 181 | return next((d.get("userid", None) for d in data if d["id"] == value), None) 182 | 183 | 184 | def id_is_connected(self, value): 185 | """ 186 | Test if device is connected by vendor and product id. 187 | 188 | Args: 189 | value (str): Vendor:Product ID 190 | 191 | Returns: 192 | bool, connected or not 193 | """ 194 | data = self.usb_devices_more() 195 | 196 | if value.startswith("host:"): 197 | value = value[5:] 198 | 199 | return any(d["id"] == value for d in data) 200 | 201 | 202 | def usb_devices(self): 203 | """ 204 | List USB devices from monitor. 205 | """ 206 | if not self.is_connected: 207 | return [] 208 | 209 | self.__write("info usb") 210 | data = self.__read() 211 | result = [] 212 | 213 | if not data: 214 | return result 215 | 216 | for line in data.splitlines(): 217 | if line[0] != " ": 218 | continue 219 | 220 | # Split line to harvest info 221 | line = line.strip().replace(", ", ",").split(",") 222 | device = {} 223 | 224 | # Add info about device to dict 225 | for element in line: 226 | key = element.lower().split(" ")[0] 227 | 228 | # ID: means the device has user-supplied ID on the host 229 | if key == "id:": 230 | device["userid"] = element[4:] 231 | else: 232 | device[key] = element[len(key)+1:] 233 | 234 | # Add device to the result 235 | result.append(device) 236 | 237 | return result 238 | 239 | 240 | def usb_devices_more(self): 241 | """ 242 | Show all USB device information from connected devices. 243 | """ 244 | return [ 245 | device for device in self.host_usb_devices_more() 246 | if "device" in device 247 | ] 248 | 249 | 250 | def host_usb_devices(self): 251 | """ 252 | List USB devices connected to host. 253 | """ 254 | if not self.is_connected: 255 | return [] 256 | 257 | self.__write("info usbhost") 258 | data = self.__read() 259 | result = [] 260 | 261 | if not data: 262 | return result 263 | 264 | for line in data.splitlines(): 265 | if line[0] != " ": 266 | continue 267 | 268 | line = line.strip().replace(", ", ",").split(",") 269 | 270 | # First line of device info starts with "Bus" 271 | if line[0][0] == "B": 272 | # Split line to harvest info 273 | device = {} 274 | 275 | # Add info about device to dict 276 | for element in line: 277 | key = element.lower().split(" ")[0] 278 | device[key] = element[len(key)+1:] 279 | 280 | # Add device to the result 281 | result.append(device) 282 | 283 | # Second line of device info starts with "Class" 284 | elif line[0][0] == "C": 285 | result[-1]["product"] = line[1] 286 | result[-1]["id"] = line[0][-9:] 287 | 288 | return result 289 | 290 | 291 | def host_usb_devices_more(self): 292 | """ 293 | Show all USB device along with their details. 294 | """ 295 | host_devices, vm_devices = self.host_usb_devices(), self.usb_devices() 296 | 297 | # Loop through both: host_device and vm_devices; compare product names 298 | # and combine dictionaries 299 | for host_device in host_devices: 300 | for vm_device in vm_devices: 301 | if vm_device.get("product", 0) == host_device.get("product", 1): 302 | host_device.update(vm_device) 303 | 304 | return host_devices -------------------------------------------------------------------------------- /qemu_usb_device_manager/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | from urllib.request import urlopen 4 | from platform import system 5 | from subprocess import check_output 6 | from . import constants 7 | 8 | 9 | def get_gateway(): 10 | """ 11 | Find gateway IP Address from ipconfig. 12 | Gateway IP Address should ideally be the host machine's local IP address. 13 | 14 | * Note: This is not good, but is the only way to do it without 15 | using external dependencies 16 | 17 | Returns: 18 | str 19 | """ 20 | if system() != "Windows": 21 | print(constants.UTIL_GATEWAY_UNSUPPORTED) 22 | return 23 | 24 | pattern = re.compile("y[\.|\ ]+:(?:\s.*?)+((?:[0-9]+\.){3}[0-9])") 25 | output = pattern.search(check_output("ipconfig").decode()) 26 | return output.group(1) if output else None 27 | 28 | 29 | def download_string(url): 30 | """ 31 | Download string from a URL. 32 | 33 | Returns: 34 | str 35 | """ 36 | return urlopen(url).read().decode("utf-8") 37 | 38 | 39 | def directories(): 40 | """ 41 | Dictionary of current, home, and base directories. 42 | 43 | Returns: 44 | dict 45 | """ 46 | return { 47 | "CURRENT_DIR": os.getcwd(), 48 | "HOME_DIR": os.path.expanduser("~"), 49 | "BASE_DIR": os.path.dirname(__file__), 50 | } 51 | 52 | 53 | def find_file(directories, filename, extensions): 54 | """ 55 | Finds specified file in specified directories. 56 | 57 | Args: 58 | directories (list): List of directories to search 59 | filename (str): Name of file to find 60 | extensions (list): List of extensions 61 | 62 | Returns: 63 | str if file found 64 | None if file not found 65 | """ 66 | for directory in directories: 67 | for extension in extensions: 68 | path = os.path.join(directory, filename + extension) 69 | if os.path.isfile(path): 70 | return path -------------------------------------------------------------------------------- /run.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from qemu_usb_device_manager import run 3 | 4 | 5 | run() -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from distutils.core import setup 3 | 4 | 5 | setup( 6 | name="qemu_usb_device_manager", 7 | version="1.1", # Remember to change version in constants.py too! 8 | description="Limited QEMU Monitor Wrapper for USB management", 9 | url="https://github.com/PassthroughPOST/qemu-usb-device-manager", 10 | py_modules=["qemu_usb_device_manager"], 11 | packages=["qemu_usb_device_manager"], 12 | license="BSD-2-Clause", 13 | entry_points={ 14 | "console_scripts": ["usb_dm=qemu_usb_device_manager:run"], 15 | }, 16 | install_requires=[ 17 | "pyyaml" 18 | ] 19 | ) --------------------------------------------------------------------------------