Hello everyone,
I recently needed a reliable solution to start VMs and LXC containers on my Proxmox node using Wake-On-LAN packets, without relying on external systems (Home Assistant, scripts on another machine, API calls, etc.).
I found the original idea here (credit where due) :
https://forum.proxmox.com/threads/wake-on-lan-wol-for-vms-and-containers.143879/ ( Thank @ EpicLPer )
Based on that concept, I rewrote a production-grade daemon with:
It is especially useful in setups involving game streaming platforms such as Moonlight/Sunshine, where a user needs to wake a Windows VM remotely without having a Proxmox account, API token or administrator access.
The VM simply turns on when the client device sends a standard WOL packet — clean, safe and completely transparent for the end-user.
Below is the full daemon script.
Proxmox WOL Daemon – VM + LXC Autostart
nano usr/local/sbin/proxmox-wol.sh
chmod 755 /usr/local/sbin/proxmox-wol.sh
nano /etc/systemd/system/proxmox-wol.service
Activation :
systemctl daemon-reload
systemctl enable --now proxmox-wol.service
systemctl status proxmox-wol.service
I recently needed a reliable solution to start VMs and LXC containers on my Proxmox node using Wake-On-LAN packets, without relying on external systems (Home Assistant, scripts on another machine, API calls, etc.).
I found the original idea here (credit where due) :
https://forum.proxmox.com/threads/wake-on-lan-wol-for-vms-and-containers.143879/ ( Thank @ EpicLPer )
Based on that concept, I rewrote a production-grade daemon with:
- automatic VM & LXC MAC discovery (via qm config and pct config)
- safe multi-segment MAC pattern matching (no accidental triggers)
- cooldown per-VM to avoid packet spam
- dual logging system (file log + optional silent/verbose modes)
- fully service-ready (supports: run, start, stop, status)
- clean code, commented in English & French
- robust PID management
- compatible with all NICs (bond, vmbr, physical interfaces)
- no manual MAC list required — everything is detected automatically
It is especially useful in setups involving game streaming platforms such as Moonlight/Sunshine, where a user needs to wake a Windows VM remotely without having a Proxmox account, API token or administrator access.
The VM simply turns on when the client device sends a standard WOL packet — clean, safe and completely transparent for the end-user.
Below is the full daemon script.
Proxmox WOL Daemon – VM + LXC Autostart
nano usr/local/sbin/proxmox-wol.sh
chmod 755 /usr/local/sbin/proxmox-wol.sh
code_language.shell:
#!/bin/bash
#
# proxmox-wol.sh
#
# FR:
# - Écoute des paquets Wake-on-LAN sur une interface donnée
# - Trouve automatiquement la VM ou le LXC à démarrer à partir de l’adresse MAC
# - Fonctionne soit comme script au premier plan (run), soit comme "daemon" simple
# avec gestion de PID (start/stop/status), utilisable via systemd.
#
# EN:
# - Listens for Wake-on-LAN packets on a given interface
# - Automatically finds the VM or LXC container to start based on the MAC address
# - Can run in foreground (run) or as a simple background daemon with PID
# (start/stop/status), suitable for systemd integration.
#
set -u # FR: Interdit l'utilisation de variables non définies
# EN: Forbid use of undefined variables (safer scripting)
### --- DEFAULT CONFIG / CONFIG PAR DÉFAUT -------------------------------- ###
# FR: Interface réseau à écouter (bond, bridge, etc.)
# EN: Network interface to listen on (bond, bridge, etc.)
INTERFACE="bond0"
# FR: Ces maps seront remplies dynamiquement par build_mac_map()
# EN: These maps are dynamically populated by build_mac_map()
# MAC_TO_VMID["bc:23:11:cf:f0:28"]="113"
# MAC_TO_TYPE["bc:23:11:cf:f0:28"]="vm"|"ct"
# MAC_HEX_PATTERN["bc:23:11:cf:f0:28"]="seg1;seg2;seg3"
declare -A MAC_TO_VMID
declare -A MAC_TO_TYPE
declare -A MAC_HEX_PATTERN
LOGFILE="/var/log/proxmox-wol.log"
ENABLE_FILE_LOG=1 # FR: 1 = log dans fichier, 0 = pas de fichier
# EN: 1 = log to file, 0 = no file logging
VERBOSE=0 # FR: 1 = logs DEBUG, 0 = non
# EN: 1 = DEBUG logs, 0 = disabled
QUIET=0 # FR: 1 = rien sur stdout, 0 = messages normaux
# EN: 1 = no stdout, 0 = normal output
COOLDOWN=15 # FR: Délai (s) mini entre deux starts de la même VM/CT
# EN: Minimum delay (s) between two starts of same VM/CT
PIDFILE="/run/proxmox-wol.pid"
### --- LOGGING ----------------------------------------------------------- ###
log_msg() {
# FR: Log normal (stdout + fichier)
# EN: Normal log (stdout + file)
local ts msg
ts="$(date '+%F %T')"
msg="$ts - $1"
if [[ "$QUIET" -eq 0 ]]; then
echo "$msg"
fi
if [[ "$ENABLE_FILE_LOG" -eq 1 ]]; then
echo "$msg" >> "$LOGFILE"
fi
}
debug_msg() {
# FR: Log DEBUG uniquement si VERBOSE=1
# EN: DEBUG log only if VERBOSE=1
[[ "$VERBOSE" -eq 1 ]] || return 0
log_msg "DEBUG: $*"
}
### --- PID MANAGEMENT / GESTION PID -------------------------------------- ###
is_running() {
# FR: Retourne 0 si le daemon tourne, 1 sinon
# EN: Returns 0 if daemon is running, 1 otherwise
if [[ -f "$PIDFILE" ]]; then
local pid
pid=$(cat "$PIDFILE" 2>/dev/null || echo "")
if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
return 0
fi
fi
return 1
}
write_pid() {
# FR: Écrit le PID courant dans le fichier PID
# EN: Writes current PID to PID file
echo "$$" > "$PIDFILE"
}
remove_pid() {
# FR: Supprime le fichier PID
# EN: Removes the PID file
rm -f "$PIDFILE"
}
### --- MAC DISCOVERY / DÉCOUVERTE MAC <-> VM/CT ------------------------- ###
#
# FR:
# - Scanne toutes les VM (qm) et tous les CT (pct)
# - Construit :
# MAC_TO_VMID[mac] = ID (VMID ou CTID)
# MAC_TO_TYPE[mac] = "vm" ou "ct"
# MAC_HEX_PATTERN[mac] = "seg1;seg2;seg3"
# où chaque segX est un bloc de 4 hex (aligné sur la sortie hexa de tcpdump).
#
# EN:
# - Scans all VMs (qm) and all containers (pct)
# - Builds:
# MAC_TO_VMID[mac] = ID (VMID or CTID)
# MAC_TO_TYPE[mac] = "vm" or "ct"
# MAC_HEX_PATTERN[mac] = "seg1;seg2;seg3"
# each segX is a 4-hex block aligned with tcpdump hex output.
build_mac_map() {
MAC_TO_VMID=()
MAC_TO_TYPE=()
MAC_HEX_PATTERN=()
local vmid ctid line mac h seg1 seg2 seg3 local_mac
####################
# VMs (QEMU / KVM) #
####################
# FR: Liste de toutes les VM Proxmox
# EN: List all Proxmox VMs
while read -r vmid; do
[[ -z "$vmid" ]] && continue
# FR: Récupère les lignes netX: de la config
# EN: Grab netX: lines from VM config
while read -r line; do
# ex: net0: virtio=BC:23:11:CF:F0:28,bridge=bond0,firewall=1
mac=$(echo "$line" \
| sed -n 's/.*\(virtio\|e1000\|rtl8139\|vmxnet3\)=\([0-9A-Fa-f:]\+\).*/\2/p')
[[ -z "$mac" ]] && continue
local_mac="${mac,,}" # normalize to lowercase
# FR: Si MAC déjà vue pour une autre VM/CT, on log et on ignore
# EN: If MAC already mapped to another VM/CT, log and ignore
if [[ -n "${MAC_TO_VMID[$local_mac]:-}" ]] && [[ "${MAC_TO_VMID[$local_mac]}" != "$vmid" ]]; then
log_msg "WARNING: MAC $local_mac already mapped to ID ${MAC_TO_VMID[$local_mac]}, ignoring VM $vmid"
continue
fi
MAC_TO_VMID["$local_mac"]="$vmid"
MAC_TO_TYPE["$local_mac"]="vm"
# FR: Construction de 3 segments hex alignés sur tcpdump
# EN: Build 3 hex segments aligned with tcpdump output
h=$(echo "$local_mac" | tr -d ':') # ex: bc2311cff028
seg1="${h:0:4}" # bc23
seg2="${h:4:4}" # 11cf
seg3="${h:8:4}" # f028
MAC_HEX_PATTERN["$local_mac"]="${seg1};${seg2};${seg3}"
done < <(qm config "$vmid" 2>/dev/null | grep -E '^net[0-9]+:' || true)
done < <(qm list | awk 'NR>1 {print $1}')
########################
# LXC Containers (pct) #
########################
# FR: Liste de tous les containers LXC
# EN: List all LXC containers
while read -r ctid; do
[[ -z "$ctid" ]] && continue
while read -r line; do
# ex: net0: name=eth0,hwaddr=BC:23:11:CF:F0:28,bridge=vmbr0,firewall=1
mac=$(echo "$line" \
| sed -n 's/.*hwaddr=\([0-9A-Fa-f:]\+\).*/\1/p')
[[ -z "$mac" ]] && continue
local_mac="${mac,,}"
if [[ -n "${MAC_TO_VMID[$local_mac]:-}" ]] && [[ "${MAC_TO_VMID[$local_mac]}" != "$ctid" ]]; then
log_msg "WARNING: MAC $local_mac already mapped to ID ${MAC_TO_VMID[$local_mac]}, ignoring CT $ctid"
continue
fi
MAC_TO_VMID["$local_mac"]="$ctid"
MAC_TO_TYPE["$local_mac"]="ct"
h=$(echo "$local_mac" | tr -d ':')
seg1="${h:0:4}"
seg2="${h:4:4}"
seg3="${h:8:4}"
MAC_HEX_PATTERN["$local_mac"]="${seg1};${seg2};${seg3}"
done < <(pct config "$ctid" 2>/dev/null | grep -E '^net[0-9]+:' || true)
done < <(pct list 2>/dev/null | awk 'NR>1 {print $1}')
local count=${#MAC_TO_VMID[@]}
log_msg "MAC->ID mapping rebuilt (${count} entries)."
# DEBUG: dump mapping if verbose
for mac in "${!MAC_TO_VMID[@]}"; do
debug_msg "MAP: MAC $mac -> ID ${MAC_TO_VMID[$mac]} (${MAC_TO_TYPE[$mac]:-vm}) pattern=${MAC_HEX_PATTERN[$mac]}"
done
}
### --- ARG PARSING / ANALYSE DES ARGUMENTS ------------------------------ ###
print_usage() {
cat <<EOF
Usage: $0 [options] {run|start|stop|status}
Options:
-i IFACE Network interface to listen on (default: $INTERFACE)
Interface réseau à écouter (défaut : $INTERFACE)
-l LOGFILE Log file path (default: $LOGFILE)
Fichier de log (défaut : $LOGFILE)
-n Disable file logging
Désactiver le log fichier
-v Verbose mode (DEBUG)
Mode verbeux (DEBUG)
-q Quiet mode (no stdout, file logging only if enabled)
Mode silencieux (pas de stdout, fichier seulement si activé)
Subcommands:
run Run listener in foreground (for systemd Type=simple)
Lancer l'écouteur au premier plan (pour systemd Type=simple)
start Start as background daemon (PID file)
Démarrer en tâche de fond (fichier PID)
stop Stop the daemon
Arrêter le daemon
status Show daemon status
Afficher l'état du daemon
Examples:
$0 -i bond0 run
$0 -i vmbr0 -v start
EOF
}
while getopts ":i:l:nvq" opt; do
case "$opt" in
i) INTERFACE="$OPTARG" ;;
l) LOGFILE="$OPTARG" ;;
n) ENABLE_FILE_LOG=0 ;;
v) VERBOSE=1 ;;
q) QUIET=1 ;;
*) print_usage; exit 1 ;;
esac
done
shift $((OPTIND - 1))
if [[ $# -lt 1 ]]; then
print_usage
exit 1
fi
COMMAND="$1"
shift || true
### --- MAIN LISTENER LOOP / BOUCLE PRINCIPALE --------------------------- ###
run_listener() {
write_pid
log_msg "Starting WOL listener on interface $INTERFACE"
build_mac_map
# FR: last_start[ID] = timestamp du dernier démarrage
# EN: last_start[ID] = timestamp of last start
declare -A last_start
# FR:
# - tcpdump:
# -l : sortie ligne par ligne
# -nn : pas de résolution DNS/service
# -vvv : détails max
# -XX : payload en hexa + ASCII
# EN:
# - tcpdump:
# -l : line-buffered output
# -nn : no DNS/service resolution
# -vvv : very verbose
# -XX : hex + ASCII payload
tcpdump -l -i "$INTERFACE" -nn -vvv -XX udp port 9 2>/dev/null | \
while read -r line; do
line_lc=${line,,} # lowercase for matching
debug_msg "$line_lc"
# FR: On teste chaque MAC connue sur chaque ligne tcpdump.
# EN: Test each known MAC against each tcpdump line.
for mac in "${!MAC_TO_VMID[@]}"; do
id="${MAC_TO_VMID[$mac]}"
type="${MAC_TO_TYPE[$mac]:-vm}" # default to "vm" if not set
pattern="${MAC_HEX_PATTERN[$mac]}"
[[ -z "$pattern" ]] && continue
IFS=';' read -r seg1 seg2 seg3 <<< "$pattern"
# FR: 3 segments MAC suffisent pour identifier la cible sans ambiguïté.
# EN: 3 MAC segments are enough to identify the target safely.
if [[ "$line_lc" == *"$seg1"* && \
"$line_lc" == *"$seg2"* && \
"$line_lc" == *"$seg3"* ]]; then
now=$(date +%s)
last=${last_start[$id]:-0}
if (( now - last < COOLDOWN )); then
log_msg "WOL for MAC $mac -> ${type^^} $id ignored (cooldown ${COOLDOWN}s)"
continue
fi
log_msg "WOL packet detected for MAC $mac -> ${type^^} $id"
# FR: Choisissez la commande en fonction du type (vm/ct)
# EN: Choose commands depending on type (vm/ct)
if [[ "$type" == "ct" ]]; then
STATUS_CMD=(pct status "$id")
START_CMD=(pct start "$id")
else
STATUS_CMD=(qm status "$id")
START_CMD=(qm start "$id")
fi
if "${STATUS_CMD[@]}" 2>/dev/null | grep -q "status: stopped"; then
log_msg "Starting ${type^^} $id..."
if "${START_CMD[@]}" >>"$LOGFILE" 2>&1; then
log_msg "${type^^} $id started successfully"
last_start[$id]=$now
else
log_msg "ERROR starting ${type^^} $id"
fi
else
log_msg "${type^^} $id is already running or locked, start ignored"
fi
fi
done
done
log_msg "Stopping WOL listener on interface $INTERFACE"
remove_pid
}
### --- COMMAND HANDLING / GESTION DES COMMANDES -------------------------- ###
case "$COMMAND" in
run)
# FR: Mode "foreground" (idéal pour systemd Type=simple)
# EN: Foreground mode (ideal for systemd Type=simple)
if is_running; then
log_msg "Listener already running (PID $(cat "$PIDFILE"))"
exit 0
fi
run_listener
;;
start)
# FR: Mode daemon simple (tâche de fond, PID géré)
# EN: Simple daemon mode (background task, PID handled)
if is_running; then
log_msg "Listener already running (PID $(cat "$PIDFILE"))"
exit 0
fi
nohup "$0" "$@" run >/dev/null 2>&1 &
sleep 1
if is_running; then
log_msg "Listener started (PID $(cat "$PIDFILE"))"
exit 0
else
log_msg "Failed to start listener"
exit 1
fi
;;
stop)
if ! is_running; then
log_msg "Listener not running"
exit 0
fi
pid=$(cat "$PIDFILE")
log_msg "Stopping listener (PID $pid)"
kill "$pid" 2>/dev/null || true
sleep 1
if is_running; then
log_msg "Listener still running, sending SIGKILL"
kill -9 "$pid" 2>/dev/null || true
fi
remove_pid
;;
status)
if is_running; then
echo "proxmox-wol: RUNNING (PID $(cat "$PIDFILE"))"
else
echo "proxmox-wol: STOPPED"
fi
;;
*)
print_usage
exit 1
;;
esac
nano /etc/systemd/system/proxmox-wol.service
code_language.shell:
[Unit]
Description=Proxmox WOL listener (VM + LXC, auto MAC mapping)
After=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/sbin/proxmox-wol.sh -i bond0 run
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
Activation :
systemctl daemon-reload
systemctl enable --now proxmox-wol.service
systemctl status proxmox-wol.service