├── LICENSE ├── README.md ├── assets └── screenrecord.gif └── vbox_web_control.py /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 nv1t 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # VirtualBox Web Control Panel 2 | 3 | A lightweight HTTP server script that offers a simple web interface for controlling and interacting with VirtualBox virtual machines. 4 | This project uses only Python 3's standard libraries, making it easy to deploy without additional dependencies. Simply place the script on any machine that has `VBoxManage` installed, and you'll be able to manage your VMs through a web browser. 5 | 6 | ## Features 7 | 8 | - **List VMs:** Fetch the list of available VirtualBox VMs. 9 | - **Control Actions:** Start, shut down, or save the state of a VM using corresponding endpoints. 10 | - **Keystroke Injection:** Convert plain text (with support for special tokens like `` or ``) into scancodes and send them to a selected VM. 11 | - **Screenshot Capture:** Retrieve a live screenshot from the VM. 12 | - **Download Screenshots:** Save the current VM screenshot as a file. 13 | - **VM Status Indicator:** Displays whether the selected VM is running or stopped. 14 | - **VM Details Sidebar:** Display detailed VM configuration information in a collapsible sidebar within the web interface. 15 | - **Responsive Web Interface:** The front-end dashboard updates periodically and provides feedback for actions executed on the VM. 16 | - **Notification System:** Displays real-time notifications for VM actions and errors. 17 | 18 | ## Demonstration 19 | 20 | Below is a demonstration of the web interface in action: 21 | 22 | ![VirtualBox Web Control Panel Demo](assets/screenrecord.gif) 23 | 24 | --- 25 | 26 | ## Prerequisites 27 | 28 | - **VirtualBox:** Ensure VirtualBox is installed and the `VBoxManage` command is available on your system's PATH. 29 | - **Python 3:** The script requires Python 3.x. It uses standard libraries such as `argparse`, `http.server`, `subprocess`, and `json`. 30 | 31 | --- 32 | 33 | ## Installation 34 | 35 | 1. Clone this repository: 36 | ```bash 37 | git clone https://github.com/nv1t/virtualbox-web-panel.git 38 | cd virtualbox-web-panel 39 | ``` 40 | 41 | 2. Make the script executable: 42 | ```bash 43 | chmod +x vbox_web_control.py 44 | ``` 45 | 46 | 3. (Optional) Create a virtual environment: 47 | ```bash 48 | python3 -m venv venv 49 | source venv/bin/activate 50 | ``` 51 | 52 | --- 53 | 54 | ## Usage 55 | 56 | Run the server: 57 | ```bash 58 | ./vbox_web_control.py --port 9091 59 | ``` 60 | 61 | Then open your browser and navigate to: 62 | ``` 63 | http://localhost:9091 64 | ``` 65 | 66 | --- 67 | 68 | ## Web Interface 69 | 70 | - **Dropdown to select VM** 71 | - **Buttons to start/save state/shutdown a VM** 72 | - **Textbox to send keystrokes** 73 | - **Live screenshot preview** 74 | - **Download screenshot button** 75 | - **VM status indicator (🟢 for running, 🔴 for stopped)** 76 | - **Collapsible sidebar for detailed VM information** 77 | - **Real-time notifications for actions and errors** 78 | 79 | You can enter plain text or special keys in angled brackets. For example: 80 | ``` 81 | cmd 82 | ``` 83 | 84 | Keep in mind, that the operating system needs time to process some inputs. 85 | 86 | ### Supported Special Keys 87 | ``` 88 | 89 | ``` 90 | 91 | --- 92 | 93 | ## API Endpoints 94 | 95 | | Endpoint | Description | 96 | |----------------------|----------------------------------------| 97 | | `/list-vms` | Lists available VirtualBox VMs | 98 | | `/control-vm` | Start/stop/save VM via query params | 99 | | `/send-keystrokes` | Send scancode-based keystrokes to VM | 100 | | `/screenshot.png` | Fetches latest screenshot of VM | 101 | | `/vm-status` | Returns JSON VM state info | 102 | | `/vm-info` | Returns detailed machine-readable information about the specified VM in JSON format. | 103 | 104 | ### API Parameter Reference 105 | 106 | #### `/control-vm` 107 | | Parameter | Type | Description | 108 | |-----------|--------|--------------------------------------| 109 | | `vm` | string | Name of the VM | 110 | | `action` | string | One of `start`, `poweroff`, `savestate` | 111 | 112 | #### `/send-keystrokes` 113 | | Parameter | Type | Description | 114 | |-----------|--------|--------------------------------------------------------------| 115 | | `vm` | string | Name of the VM | 116 | | `keys` | string | Keystrokes, can include special tokens like ``, `` | 117 | 118 | #### `/screenshot.png` 119 | | Parameter | Type | Description | 120 | |-----------|--------|-----------------------------------------------| 121 | | `vm` | string | Name of the VM | 122 | | `download`| string | Optional. Set to `1` to download the screenshot| 123 | 124 | #### `/vm-status` 125 | | Parameter | Type | Description | 126 | |-----------|--------|----------------| 127 | | `vm` | string | Name of the VM | 128 | 129 | #### `/vm-info` 130 | | Parameter | Type | Description | 131 | |-----------|--------|----------------| 132 | | `vm` | string | Name of the VM | 133 | 134 | --- 135 | 136 | ## Contributing 137 | 138 | Contributions and feedback are welcome! Feel free to fork the repository and submit pull requests to improve functionality, add features, or correct issues. 139 | 140 | --- 141 | 142 | ## Future Improvements 143 | 144 | - Support for combo key combinations (e.g., `++del`) 145 | - Separate Frontend/Backend Code and use a make style script to generate a "release" -> easier to maintain. 146 | - Authentication layer for control access 147 | - Configurable polling intervals for screenshots 148 | - Command history/autocomplete in input box 149 | - Delay Parameter and/or Ducky Script Inputs 150 | 151 | -------------------------------------------------------------------------------- /assets/screenrecord.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nv1t/virtualbox-web-panel/b5164a27cb0cdd8956134abc6bb3be31dec8d297/assets/screenrecord.gif -------------------------------------------------------------------------------- /vbox_web_control.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import argparse 3 | import http.server 4 | import urllib.parse 5 | import subprocess 6 | import os 7 | import json 8 | import re 9 | import socket # Needed for error checking in run_server 10 | from typing import List, Dict 11 | import signal # Import signal for handling termination signals 12 | 13 | # Mapping of characters to their corresponding keyboard scancodes. 14 | KEYCODES = { 15 | 'a': '1e', 'b': '30', 'c': '2e', 'd': '20', 'e': '12', 16 | 'f': '21', 'g': '22', 'h': '23', 'i': '17', 'j': '24', 17 | 'k': '25', 'l': '26', 'm': '32', 'n': '31', 'o': '18', 18 | 'p': '19', 'q': '10', 'r': '13', 's': '1f', 't': '14', 19 | 'u': '16', 'v': '2f', 'w': '11', 'x': '2d', 'y': '15', 20 | 'z': '2c', 21 | '0': '0b', '1': '02', '2': '03', '3': '04', '4': '05', 22 | '5': '06', '6': '07', '7': '08', '8': '09', '9': '0a', 23 | ' ': '39', 24 | '-': '0c', '=': '0d', '[': '1a', ']': '1b', '\\': '2b', 25 | ';': '27', '\'': '28', '`': '29', ',': '33', '.': '34', '/': '35', 26 | '!': '02', '@': '03', '#': '04', '$': '05', '%': '06', '^': '07', 27 | '&': '08', '*': '09', '(': '0a', ')': '0b', '_': '0c', '+': '0d', 28 | '{': '1a', '}': '1b', '|': '2b', ':': '27', '"': '28', '~': '29', 29 | '<': '33', '>': '34', '?': '35' 30 | } 31 | 32 | # Scancode sequences for special keys. 33 | SPECIAL_KEYCODES = { 34 | "backspace": ["0e", "8e"], 35 | "insert": ["e0", "52", "e0", "d2"], 36 | "home": ["e0", "47", "e0", "c7"], 37 | "end": ["e0", "4f", "e0", "cf"], 38 | "pageup": ["e0", "49", "e0", "c9"], 39 | "pagedown": ["e0", "51", "e0", "d1"], 40 | "left": ["e0", "4b", "e0", "cb"], 41 | "right": ["e0", "4d", "e0", "cd"], 42 | "up": ["e0", "48", "e0", "c8"], 43 | "down": ["e0", "50", "e0", "d0"], 44 | "ctrl": ["1d", "9d"], 45 | "strg": ["1d", "9d"], 46 | "shift": ["2a", "aa"], 47 | "alt": ["38", "b8"], 48 | "win": ["e0", "5b", "e0", "db"], 49 | "windows": ["e0", "5b", "e0", "db"], 50 | "esc": ["01", "81"], 51 | "escape": ["01", "81"], 52 | "enter": ["1c", "9c"], 53 | "return": ["1c", "9c"], 54 | "tab": ["0f", "8f"], 55 | "capslock": ["3a", "ba"], 56 | "f1": ["3b", "bb"], "f2": ["3c", "bc"], "f3": ["3d", "bd"], "f4": ["3e", "be"], 57 | "f5": ["3f", "bf"], "f6": ["40", "c0"], "f7": ["41", "c1"], "f8": ["42", "c2"], 58 | "f9": ["43", "c3"], "f10": ["44", "c4"], "f11": ["57", "d7"], "f12": ["58", "d8"], 59 | "del": ["53", "d3"], "delete": ["53", "d3"] 60 | } 61 | 62 | # Mapping for characters that require the Shift modifier. 63 | SHIFT_REQUIRED = { 64 | '!': '1', '@': '2', '#': '3', '$': '4', '%': '5', '^': '6', 65 | '&': '7', '*': '8', '(': '9', ')': '0', 66 | '_': '-', '+': '=', '{': '[', '}': ']', '|': '\\', 67 | ':': ';', '"': '28', '~': '29', '<': '33', '>': '34', '?': '35' 68 | } 69 | SHIFT_REQUIRED.update({chr(c): chr(c).lower() for c in range(ord('A'), ord('Z') + 1)}) 70 | 71 | def text_to_scancodes(text: str) -> List[str]: 72 | """ 73 | Converts a given text string into a sequence of keyboard scancodes. 74 | If a character requires the Shift modifier, the necessary scancodes are added. 75 | 76 | Args: 77 | text (str): The input text to convert. 78 | 79 | Returns: 80 | List[str]: A list of scancodes representing the input text. 81 | """ 82 | codes = [] 83 | for ch in text: 84 | if ch in SHIFT_REQUIRED: 85 | base = SHIFT_REQUIRED[ch] 86 | if base not in KEYCODES: 87 | continue 88 | sc = KEYCODES[base] 89 | codes.extend(["2a", sc, format(int(sc, 16) + 0x80, 'x'), "aa"]) 90 | elif ch.isupper(): 91 | lower = ch.lower() 92 | if lower not in KEYCODES: 93 | continue 94 | sc = KEYCODES[lower] 95 | codes.extend(["2a", sc, format(int(sc, 16) + 0x80, 'x'), "aa"]) 96 | elif ch in KEYCODES: 97 | sc = KEYCODES[ch] 98 | codes.extend([sc, format(int(sc, 16) + 0x80, 'x')]) 99 | else: 100 | continue 101 | return codes 102 | 103 | def parse_keys_input(input_str: str) -> List[str]: 104 | """ 105 | Parses a string containing tokens (e.g., , ) and converts regular text to scancodes. 106 | 107 | Args: 108 | input_str (str): The input string containing tokens and/or text. 109 | 110 | Returns: 111 | List[str]: A list of scancodes representing the input string. 112 | """ 113 | tokens = re.split(r'(<[^>]+>)', input_str) 114 | codes = [] 115 | for token in tokens: 116 | if not token: 117 | continue 118 | if token.startswith("<") and token.endswith(">"): 119 | key_name = token[1:-1].lower() 120 | if key_name in SPECIAL_KEYCODES: 121 | codes.extend(SPECIAL_KEYCODES[key_name]) 122 | else: 123 | codes.extend(text_to_scancodes(token)) 124 | return codes 125 | 126 | def run_vboxmanage_command(args: List[str]) -> subprocess.CompletedProcess: 127 | """ 128 | Executes a VBoxManage command with the given arguments. 129 | 130 | Args: 131 | args (List[str]): A list of arguments for the VBoxManage command. 132 | 133 | Returns: 134 | subprocess.CompletedProcess: The result of the command execution. 135 | 136 | Raises: 137 | subprocess.CalledProcessError: If the command fails. 138 | """ 139 | cmd = ["VBoxManage"] + args 140 | return subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) 141 | 142 | class VirtualBoxHandler(http.server.BaseHTTPRequestHandler): 143 | """ 144 | HTTP request handler for managing VirtualBox VMs via a web interface. 145 | """ 146 | 147 | def safe_write(self, data: bytes) -> None: 148 | """ 149 | Safely writes data to the client, ignoring broken connections. 150 | 151 | Args: 152 | data (bytes): The data to write to the client. 153 | """ 154 | try: 155 | self.wfile.write(data) 156 | except (BrokenPipeError, ConnectionResetError): 157 | print("Client disconnected before response completed.") 158 | 159 | def do_GET(self) -> None: 160 | """ 161 | Handles HTTP GET requests and routes them to the appropriate endpoint. 162 | """ 163 | parsed_url = urllib.parse.urlparse(self.path) 164 | route = parsed_url.path 165 | params = urllib.parse.parse_qs(parsed_url.query) 166 | 167 | # Endpoint: List available VMs. 168 | if route == "/list-vms": 169 | try: 170 | completed = run_vboxmanage_command(["list", "vms"]) 171 | output = completed.stdout 172 | vm_names = re.findall(r'"([^"]+)"', output) 173 | self.send_response(200) 174 | self.send_header("Content-type", "application/json") 175 | self.end_headers() 176 | self.safe_write(json.dumps(vm_names).encode()) 177 | except subprocess.CalledProcessError as e: 178 | self.send_response(500) 179 | self.send_header("Content-type", "text/plain") 180 | self.end_headers() 181 | self.safe_write(f"Error listing VMs:\n{e.stderr}".encode()) 182 | return 183 | 184 | # Main HTML Frontend. 185 | if route == "/": 186 | html = """ 187 | 188 | 189 | 190 | VirtualBox Control Panel 191 | 334 | 515 | 516 | 517 |
518 | 519 | 520 | 521 | 522 | 523 |
524 | 525 | 528 | 529 |
530 |
531 | VM Screenshot 532 |
533 | 534 |
535 |
536 | 537 | 538 |
539 |
540 | 541 |
542 |
543 | 544 |
◀ VM Details
545 |
546 |
Loading VM info...
547 |
548 | 549 | 550 | """ 551 | self.send_response(200) 552 | self.send_header("Content-type", "text/html") 553 | self.end_headers() 554 | self.safe_write(html.encode()) 555 | return 556 | 557 | # Endpoint: Control VM actions. 558 | if route == "/control-vm": 559 | vm_name = params.get("vm", ["myVM"])[0] 560 | action = params.get("action", [""])[0].lower() 561 | if action not in ["start", "poweroff", "savestate"]: 562 | self.send_response(400) 563 | self.send_header("Content-type", "text/plain") 564 | self.end_headers() 565 | self.safe_write(b"Invalid VM action requested.") 566 | return 567 | try: 568 | if action == "start": 569 | run_vboxmanage_command(["startvm", vm_name, "--type", "headless"]) 570 | else: 571 | run_vboxmanage_command(["controlvm", vm_name, action]) 572 | self.send_response(200) 573 | self.send_header("Content-type", "text/plain") 574 | self.end_headers() 575 | self.safe_write(f"Action '{action}' executed on VM '{vm_name}'.".encode()) 576 | except subprocess.CalledProcessError as e: 577 | self.send_response(500) 578 | self.send_header("Content-type", "text/plain") 579 | self.end_headers() 580 | self.safe_write(f"Error executing action '{action}' on VM '{vm_name}': {e.stderr}".encode()) 581 | return 582 | 583 | # Endpoint: Fetch screenshot. 584 | if route == "/screenshot.png": 585 | vm_name = params.get("vm", ["myVM"])[0] 586 | screenshot_path = "screenshot.png" 587 | try: 588 | run_vboxmanage_command(["controlvm", vm_name, "screenshotpng", screenshot_path]) 589 | self.send_response(200) 590 | self.send_header("Content-type", "image/png") 591 | # NEW: Check if download parameter is present, and add content-disposition. 592 | if params.get("download", ["0"])[0] == "1": 593 | self.send_header("Content-Disposition", "attachment; filename=\"screenshot.png\"") 594 | self.end_headers() 595 | with open(screenshot_path, "rb") as f: 596 | content = f.read() 597 | self.safe_write(content) 598 | except subprocess.CalledProcessError: 599 | self.send_response(200) 600 | self.send_header("Content-type", "image/svg+xml") 601 | if params.get("download", ["0"])[0] == "1": 602 | self.send_header("Content-Disposition", "attachment; filename=\"screenshot.svg\"") 603 | self.end_headers() 604 | self.safe_write(b"No Image available") 605 | return 606 | 607 | # Endpoint: Send keystrokes. 608 | if route == "/send-keystrokes": 609 | vm_name = params.get("vm", ["myVM"])[0] 610 | if "keys" not in params: 611 | self.send_response(400) 612 | self.send_header("Content-type", "text/plain") 613 | self.end_headers() 614 | self.safe_write(b"Missing required query parameter 'keys'.") 615 | return 616 | keys_input = params["keys"][0] 617 | scancodes = parse_keys_input(keys_input) 618 | if not scancodes: 619 | self.send_response(400) 620 | self.send_header("Content-type", "text/plain") 621 | self.end_headers() 622 | self.safe_write(b"Unable to convert input string to scancodes.") 623 | return 624 | try: 625 | run_vboxmanage_command(["controlvm", vm_name, "keyboardputscancode"] + scancodes) 626 | self.send_response(200) 627 | self.send_header("Content-type", "text/plain") 628 | self.end_headers() 629 | self.safe_write(f"Keystrokes sent to VM '{vm_name}' successfully.".encode()) 630 | except subprocess.CalledProcessError as e: 631 | self.send_response(500) 632 | self.send_header("Content-type", "text/plain") 633 | self.end_headers() 634 | self.safe_write(f"Error sending keystrokes to VM '{vm_name}': {e.stderr}".encode()) 635 | return 636 | 637 | # Endpoint: Return VM status. 638 | if route == "/vm-status": 639 | vm_name = params.get("vm", ["myVM"])[0] 640 | try: 641 | result = run_vboxmanage_command(["showvminfo", vm_name, "--machinereadable"]) 642 | running = 'VMState="running"' in result.stdout 643 | self.send_response(200) 644 | self.send_header("Content-type", "application/json") 645 | self.end_headers() 646 | self.safe_write(json.dumps({"running": running}).encode()) 647 | except subprocess.CalledProcessError as e: 648 | self.send_response(500) 649 | self.send_header("Content-type", "application/json") 650 | self.end_headers() 651 | self.safe_write(json.dumps({"error": e.stderr}).encode()) 652 | return 653 | 654 | # Endpoint: Return detailed VM info. 655 | if route == "/vm-info": 656 | vm_name = params.get("vm", ["myVM"])[0] 657 | try: 658 | result = run_vboxmanage_command(["showvminfo", vm_name, "--machinereadable"]) 659 | info_lines = result.stdout.splitlines() 660 | info = {} 661 | for line in info_lines: 662 | if "=" in line: 663 | key, value = line.split("=", 1) 664 | key = key.strip() 665 | value = value.strip().strip('"') 666 | info[key] = value 667 | self.send_response(200) 668 | self.send_header("Content-type", "application/json") 669 | self.end_headers() 670 | self.safe_write(json.dumps(info, indent=2).encode()) 671 | except subprocess.CalledProcessError as e: 672 | self.send_response(500) 673 | self.send_header("Content-type", "application/json") 674 | self.end_headers() 675 | self.safe_write(json.dumps({"error": e.stderr}).encode()) 676 | return 677 | 678 | # 404 Not Found. 679 | self.send_response(404) 680 | self.send_header("Content-type", "text/plain") 681 | self.end_headers() 682 | self.safe_write(b"Route not found.\n") 683 | 684 | # Start the server on the specified port. 685 | def run_server(start_port: int, max_tries: int = 10) -> None: 686 | """ 687 | Starts the HTTP server on the specified port, trying multiple ports if the initial one is unavailable. 688 | 689 | Args: 690 | start_port (int): The starting port for the server. 691 | max_tries (int): The maximum number of ports to try. 692 | 693 | Raises: 694 | OSError: If the server cannot start after trying the specified number of ports. 695 | """ 696 | for i in range(max_tries): 697 | port = start_port + i 698 | try: 699 | server_address = ("", port) 700 | httpd = http.server.HTTPServer(server_address, VirtualBoxHandler) 701 | print(f"Server started on port {port}.") 702 | try: 703 | httpd.serve_forever() 704 | except KeyboardInterrupt: 705 | print("Server is shutting down.") 706 | httpd.server_close() 707 | break 708 | except OSError as e: 709 | if e.errno == socket.errno.EADDRINUSE: 710 | print(f"Port {port} is in use, trying next...") 711 | else: 712 | raise 713 | else: 714 | print(f"Could not start server after trying {max_tries} ports.") 715 | 716 | def cleanup(): 717 | """ 718 | Deletes the screenshot.png file if it exists. 719 | """ 720 | screenshot_path = "screenshot.png" 721 | if os.path.exists(screenshot_path): 722 | os.remove(screenshot_path) 723 | print("Cleanup: Deleted screenshot.png") 724 | 725 | def signal_handler(sig, frame): 726 | """ 727 | Handles termination signals and performs cleanup. 728 | """ 729 | print("Signal received, performing cleanup...") 730 | cleanup() 731 | exit(0) 732 | 733 | # Register signal handlers for cleanup 734 | signal.signal(signal.SIGINT, signal_handler) # Handle Ctrl+C 735 | signal.signal(signal.SIGTERM, signal_handler) # Handle termination signals 736 | 737 | if __name__ == "__main__": 738 | parser = argparse.ArgumentParser(description="Start the VirtualBox Web Control Panel.") 739 | parser.add_argument("--port", type=int, default=9091, help="Starting port for the server") 740 | args = parser.parse_args() 741 | run_server(args.port) 742 | cleanup() # Ensure cleanup is called after the server stops 743 | --------------------------------------------------------------------------------