├── .gitignore ├── go.mod ├── init-scripts ├── openrc │ ├── virtwold.confd │ └── virtwold.initd └── systemd │ └── virtwold@.service ├── LICENSE ├── go.sum ├── README.md └── virtwold.go /.gitignore: -------------------------------------------------------------------------------- 1 | virtwold 2 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/scottesandiego/virtwold/v2 2 | 3 | go 1.25 4 | 5 | require ( 6 | github.com/google/gopacket v1.1.19 7 | libvirt.org/go/libvirt v1.11006.0 8 | libvirt.org/go/libvirtxml v1.11008.0 9 | ) 10 | 11 | require ( 12 | golang.org/x/net v0.46.0 // indirect 13 | golang.org/x/sys v0.37.0 // indirect 14 | ) 15 | -------------------------------------------------------------------------------- /init-scripts/openrc/virtwold.confd: -------------------------------------------------------------------------------- 1 | # /etc/conf.d/virtwold 2 | # 3 | # Specify the interface to be used 4 | VIRTWOLD_INTERFACE="eth0" 5 | 6 | # Specify the libvirt URI to connect to 7 | # Default: qemu+tcp:///system 8 | # Examples: 9 | # qemu:///system - Local QEMU/KVM connection via Unix socket 10 | # qemu+tcp:///system - Local QEMU/KVM connection via TCP 11 | # qemu+ssh://user@host/system - Remote connection via SSH 12 | VIRTWOLD_LIBVIRTURI="qemu+tcp:///system" 13 | -------------------------------------------------------------------------------- /init-scripts/systemd/virtwold@.service: -------------------------------------------------------------------------------- 1 | ; An example systemd service template for virtwold 2 | ; Using a service template lets you start multiple copies 3 | ; of the daemon and control which interface it listens to 4 | ; by doing something like: systemctl enable --now virtwold@br1 5 | ; to start the daemon on the br1 network interface 6 | ; 7 | ; This will let you start multiple daemons easily on every interface 8 | ; that you need things to work with 9 | [Unit] 10 | Description=libvirt wake on lan daemon 11 | After=network.target 12 | Wants=libvirtd.service 13 | 14 | [Service] 15 | Type=simple 16 | 17 | ; You'll want to update the path here to where you place the final compiled binary 18 | ExecStart=/usr/local/bin/virtwold -interface %i 19 | Restart=on-failure 20 | RestartSec=30s 21 | 22 | [Install] 23 | WantedBy=multi-user.target 24 | -------------------------------------------------------------------------------- /init-scripts/openrc/virtwold.initd: -------------------------------------------------------------------------------- 1 | #!/sbin/openrc-run 2 | # Copyright 2021 Gentoo Authors 3 | # Distributed under the terms of the GNU General Public License v2 4 | 5 | name="virtwold daemon" 6 | description="Virtual Wake-on-LAN Daemon" 7 | command=/usr/bin/virtwold 8 | command_args="--interface ${VIRTWOLD_INTERFACE} --libvirturi ${VIRTWOLD_LIBVIRTURI:-qemu+tcp:///system}" 9 | 10 | PIDFILE=/run/virtwold.pid 11 | 12 | depend() { 13 | need net 14 | use libvirtd 15 | } 16 | 17 | start() { 18 | ebegin "Starting ${name}" 19 | start-stop-daemon \ 20 | --start \ 21 | --exec ${command} \ 22 | --background \ 23 | --stdout-logger /usr/bin/logger \ 24 | --stderr-logger /usr/bin/logger \ 25 | --make-pidfile \ 26 | --pidfile "${PIDFILE}" \ 27 | -- ${command_args} 28 | eend $? 29 | } 30 | 31 | stop() { 32 | ebegin "Stopping ${name}" 33 | start-stop-daemon \ 34 | --stop \ 35 | --pidfile "${PIDFILE}" 36 | eend $? 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (c) 2021, Scott Ellis 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 17 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 18 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 20 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 21 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 22 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 24 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 25 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= 2 | github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo= 3 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 4 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 5 | golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= 6 | golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= 7 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 8 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 9 | golang.org/x/net v0.5.0 h1:GyT4nK/YDHSqa1c4753ouYCDajOYKTja9Xb/OHtgvSw= 10 | golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= 11 | golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= 12 | golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= 13 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 14 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 15 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 16 | golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= 17 | golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 18 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 19 | golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 20 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 21 | libvirt.org/go/libvirt v1.11006.0 h1:xzF87ptj/7cp1h4T62w1ZMBVY8m0mQukSCstMgeiVLs= 22 | libvirt.org/go/libvirt v1.11006.0/go.mod h1:1WiFE8EjZfq+FCVog+rvr1yatKbKZ9FaFMZgEqxEJqQ= 23 | libvirt.org/go/libvirtxml v1.11008.0 h1:R5nffNDkOn3bndLiOXvmvld38J5QLZ2D6ZJdLOYOYBg= 24 | libvirt.org/go/libvirtxml v1.11008.0/go.mod h1:7Oq2BLDstLr/XtoQD8Fr3mfDNrzlI3utYKySXF2xkng= 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # virtwold 2 | Wake-on-LAN for libvirt based VMs 3 | 4 | ## Introduction 5 | This is a daemon which listens for wake-on-LAN ("WOL") packets, and upon spotting one, tries to start the virtual machine with the associated MAC address. 6 | 7 | One use-case (my use case) is to have a gaming VM that doesn't need to be running all the time. NVIDIA Gamestream and Moonlight both have the ability to send WOL packets in an attempt to wake an associated system. For "real" hardware, this works great. Unfortunately, for VMs it doesn't really do anything since there's no physical NIC snooping for the WOL packet. This daemon attempts to solve that. 8 | 9 | ## Mechanics 10 | When started, this daemon will use `libpcap` to make a listener on the specified network interface, listening for packets that look like they might be wake-on-lan. Due to how `pcap` works, the current filter is for UDP sent to the broadcast address with a length of 234 bytes (the size of a WOL packet w/security). This seems to generate very low false-positives, doesn't require the NIC to be in promiscuous mode, and overall seems like a decent filter. 11 | 12 | Upon receipt of a (probable) WOL packet, the daemon extracts the first MAC address (WOL packets are supposed to repeat the target machine MAC a few times). 13 | 14 | With a MAC address in-hand, the program then connects to a `libvirtd` daemon via , supplied libvirt URI and gets an XML formatted list of every Virtual Machine configured (yuck), and iterates through all interfaces getting the MAC address. That MAC is then compared with the MAC from the WOL packet. If a match is found, the `libvirtd` daemon is asked to start the associated VM. 15 | 16 | ## Usage 17 | Usage is pretty staightforward, as the command needs two arguments: 18 | 1. The name of the network interface to listen on. Specify this with the `--interface` flag (e.g., `--interface enp44s0`). 19 | 2. The URI to the `libvirtd` to be used. Specify this with the `--libvirturi` flag (e.g., `qemu+tcp:///system`). 20 | 21 | 22 | The daemon will keep running until killed with a SIGINT (`^c`). 23 | 24 | Because this daemon, and wake-on-LAN, operate by MAC addresses, any VMs that are a candidate to be woken must have a hard-coded MAC in their machine configuration. 25 | 26 | ## System Integration 27 | 28 | ### systemd example service 29 | There's a systemd service template example in `init-scripts/systemd/virtwold@.service` that should make it easy to configure for any interfaces that you need to run on 30 | 31 | ## OpenRC example init script 32 | Systems which use openrc can find an example init script and associated conf file in `init-scripts/openrc/`. The interface should be adjusted to match your particular needs (e.g., swap `eth0` for `enp44s0` or something like that). 33 | 34 | ## Gentoo ebuild 35 | An ebuild for Gentoo systems is available in [here](https://github.com/ScottESanDiego/scotterepo/tree/main/app-emulation/virtwold), although it only installs OpenRC init files (since that's what I use). 36 | 37 | ## Archlinux PKGBUILD 38 | Users of Arch can install `virtwold` from the PKGBUILD in AUR located [here](https://aur.archlinux.org/packages/virtwold). 39 | -------------------------------------------------------------------------------- /virtwold.go: -------------------------------------------------------------------------------- 1 | // 2 | // Virtual Wake-on-LAN 3 | // 4 | // Listens for a WOL magic packet (UDP), then connects to libvirt and finds a matching inactive VM 5 | // If a matching VM is found and is not running, it is started 6 | // 7 | // Assumes the VM has a static MAC configured 8 | // Uses configurable libvirt URI (default: qemu+tcp:///system) 9 | // 10 | // Filters on len=102 and len=144 (WOL packet) and len=234 (WOL packet with password) 11 | 12 | package main 13 | 14 | import ( 15 | "errors" 16 | "flag" 17 | "fmt" 18 | "log" 19 | 20 | "github.com/google/gopacket" 21 | "github.com/google/gopacket/pcap" 22 | "libvirt.org/go/libvirt" 23 | "libvirt.org/go/libvirtxml" 24 | ) 25 | 26 | const ( 27 | // WOL packet structure 28 | wolHeaderSize = 6 // 6 bytes of 0xFF 29 | wolMACSize = 6 // MAC address is 6 bytes 30 | wolMACRepeats = 16 // MAC repeated 16 times 31 | wolMinSize = wolHeaderSize + (wolMACSize * wolMACRepeats) // 102 bytes minimum 32 | ) 33 | 34 | func main() { 35 | var iface string // Interface we'll listen on 36 | var libvirturi string // URI to the libvirt daemon 37 | var buffer = int32(160) // Small buffer for WOL packets with headers 38 | // Optimized BPF filter: UDP port 9 (standard WOL port), reasonable packet size 39 | // Note: 'greater' checks total packet length (headers + payload), not just UDP payload 40 | var filter = "udp and dst port 9 and greater 100" 41 | 42 | flag.StringVar(&iface, "interface", "eth0", "Network interface name to listen on") 43 | flag.StringVar(&libvirturi, "libvirturi", "qemu+tcp:///system", "URI to libvirt daemon, such as qemu:///system") 44 | flag.Parse() 45 | 46 | if !deviceExists(iface) { 47 | log.Fatalf("Unable to open device: %s", iface) 48 | } 49 | 50 | handler, err := pcap.OpenLive(iface, buffer, false, pcap.BlockForever) 51 | if err != nil { 52 | log.Fatalf("failed to open device: %v", err) 53 | } 54 | defer handler.Close() 55 | 56 | if err := handler.SetBPFFilter(filter); err != nil { 57 | log.Fatalf("Something in the BPF went wrong!: %v", err) 58 | } 59 | 60 | // Handle every packet received, looping forever 61 | log.Printf("Listening for WOL packets on %s (libvirt URI: %s)", iface, libvirturi) 62 | source := gopacket.NewPacketSource(handler, handler.LinkType()) 63 | for packet := range source.Packets() { 64 | // Called for each packet received 65 | log.Printf("Received potential WOL packet") 66 | mac, err := GrabMACAddr(packet) 67 | if err != nil { 68 | log.Printf("Warning: Error parsing packet: %v", err) 69 | continue 70 | } 71 | if err := WakeVirtualMachine(mac, libvirturi); err != nil { 72 | log.Printf("Error waking virtual machine: %v", err) 73 | } 74 | } 75 | } 76 | 77 | // Extract and validate MAC address from WOL magic packet 78 | // WOL packet structure: 6 bytes of 0xFF + MAC repeated 16 times + optional password 79 | func GrabMACAddr(packet gopacket.Packet) (string, error) { 80 | app := packet.ApplicationLayer() 81 | if app == nil { 82 | return "", errors.New("no application layer found in packet") 83 | } 84 | 85 | payload := app.Payload() 86 | if len(payload) < wolMinSize { 87 | return "", fmt.Errorf("payload too short: got %d bytes, need at least %d", len(payload), wolMinSize) 88 | } 89 | 90 | // Validate sync stream: first 6 bytes must be 0xFF 91 | for i := 0; i < wolHeaderSize; i++ { 92 | if payload[i] != 0xFF { 93 | return "", fmt.Errorf("invalid WOL header: byte %d is 0x%02x, expected 0xFF", i, payload[i]) 94 | } 95 | } 96 | 97 | // Extract MAC from first repetition (bytes 6-11) 98 | macOffset := wolHeaderSize 99 | mac := fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x", 100 | payload[macOffset], payload[macOffset+1], payload[macOffset+2], 101 | payload[macOffset+3], payload[macOffset+4], payload[macOffset+5]) 102 | log.Printf("Validated WOL packet for MAC: %s", mac) 103 | return mac, nil 104 | } 105 | 106 | func WakeVirtualMachine(mac string, libvirturi string) error { 107 | // Connect to the local libvirt socket 108 | connection, err := libvirt.NewConnect(libvirturi) 109 | if err != nil { 110 | return fmt.Errorf("failed to connect to libvirt: %w", err) 111 | } 112 | defer connection.Close() 113 | 114 | // Get a list of all inactive VMs (aka Domains) configured so we can loop through them 115 | domains, err := connection.ListAllDomains(libvirt.CONNECT_LIST_DOMAINS_INACTIVE) 116 | if err != nil { 117 | return fmt.Errorf("failed to retrieve domains: %w", err) 118 | } 119 | 120 | for _, domain := range domains { 121 | // Now we get the XML Description for each domain 122 | xmldesc, err := domain.GetXMLDesc(0) 123 | if err != nil { 124 | log.Printf("Warning: Failed retrieving XML for domain: %v", err) 125 | continue 126 | } 127 | 128 | // Get the details for each domain 129 | domcfg := &libvirtxml.Domain{} 130 | err = domcfg.Unmarshal(xmldesc) 131 | if err != nil { 132 | log.Printf("Warning: Failed parsing domain configuration: %v", err) 133 | continue 134 | } 135 | 136 | // Loop through each interface found 137 | for _, iface := range domcfg.Devices.Interfaces { 138 | domainmac := iface.MAC.Address 139 | 140 | if domainmac == mac { 141 | // We'll use the name later, so may as well get it here 142 | name := domcfg.Name 143 | 144 | // Get the state of the VM and take action 145 | state, _, err := domain.GetState() 146 | if err != nil { 147 | log.Printf("Warning: Failed to check domain state for %s: %v", name, err) 148 | continue 149 | } 150 | 151 | // Print an informative message about the state of things 152 | switch state { 153 | case libvirt.DOMAIN_SHUTDOWN, libvirt.DOMAIN_SHUTOFF, libvirt.DOMAIN_CRASHED: 154 | log.Printf("Waking system: %s at MAC %s", name, mac) 155 | 156 | case libvirt.DOMAIN_PMSUSPENDED: 157 | log.Printf("Unsuspending system: %s at MAC %s", name, mac) 158 | 159 | case libvirt.DOMAIN_PAUSED: 160 | log.Printf("Resuming system: %s at MAC %s", name, mac) 161 | 162 | default: 163 | log.Printf("System %s at MAC %s is already running (state: %d)", name, mac, state) 164 | return nil 165 | } 166 | 167 | // Try and start the VM 168 | err = domain.Create() 169 | if err != nil { 170 | return fmt.Errorf("failed to start domain %s: %w", name, err) 171 | } 172 | log.Printf("Successfully started domain: %s", name) 173 | return nil 174 | } 175 | } 176 | } 177 | 178 | return fmt.Errorf("no inactive domain found with MAC address: %s", mac) 179 | } 180 | 181 | // Check if the network device exists 182 | func deviceExists(interfacename string) bool { 183 | if interfacename == "" { 184 | log.Println("Error: No interface to listen on specified") 185 | flag.PrintDefaults() 186 | return false 187 | } 188 | devices, err := pcap.FindAllDevs() 189 | 190 | if err != nil { 191 | log.Printf("Error: Failed to find network devices: %v", err) 192 | return false 193 | } 194 | 195 | for _, device := range devices { 196 | if device.Name == interfacename { 197 | return true 198 | } 199 | } 200 | return false 201 | } 202 | --------------------------------------------------------------------------------