[TUTORIAL] Advanced Wake-On-LAN daemon for Proxmox (VM + LXC, auto-MAC mapping, cooldown, logging, systemd-ready)

RedLemon

New Member
Dec 9, 2025
1
1
3
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:
  • 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
This behaves exactly like “Wake-On-LAN for virtual machines” and integrates perfectly inside a Proxmox node.

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
 
  • Like
Reactions: UdoB