Wake-on-LAN 的另類玩法:WoL feat. MQTT、ESP8266、reverse SSH
前言
Ken 又給了我一台閒置的小主機 UAP-server,讓我自由發揮。他唯一的要求是:平時關機,要用再開起來,用完再關掉;然後撂下一句:「怎麼處理,您自己看著辦。」
平時關機好辦,難在「要用再開起來」:人不在機器旁邊,得從遠端的本站伺服器(waterfalls)把它叫醒。這正是 Wake-on-LAN(WoL)的用途:對目標電腦的網卡送一個「magic packet」,把它從關機狀態叫醒。實際做下去,卻一路過了好幾關。這篇記錄最後的做法,以及過程中踩到的坑。
第一關:網卡與 BIOS
- 無線網卡不支援喚醒。UAP 原本只連 WiFi,無線網卡是 Qualcomm Atheros AR9485,Linux 下用
ath9k驅動,這個驅動不支援無線喚醒(WoWLAN)。主機板上另外有 6 個 Intel I211 有線網孔,這款網卡的 WoL 很穩,所以改接一條網路線。 - BIOS 裡找不到 WoL 選項。這台機器是 AMI Aptio BIOS,翻遍了都沒看到相關設定,所以改在作業系統這一層處理。要注意的是,網卡的 WoL 設定在關機後會退回預設的「關閉」,每次開機都得重新開啟一次;因此寫了一個開機時自動執行的小服務,對每個有線網孔下指令開啟 WoL。
ethtool -s <網孔> wol g
實測在 BIOS 完全不設定的情況下,照樣叫得醒。
第二關:外面的封包根本進不來
UAP 接的是電信 4G 分享器。這類網路的電信商在上游多做了一層位址轉換,外面的連線根本到不了家裡的分享器,所以在分享器上設 port forward 也沒有用。magic packet 只能從同一個區網裡送出。
最早的解法,是借用區網裡另一台小主機當中繼:本站透過 reverse SSH 通道登入那台小主機,由它執行 wakeonlan。實測單發一次偶爾會掉,改成連送 5 次、間隔 3 秒就很穩,10~30 秒內會醒。
但那台小主機平常也會關機,變成「要叫醒 A,得先確定 B 開著」。
第三關:一塊常駐的 ESP8266
關鍵在於,外面連不進來,但裡面可以主動連出去。
所以在同一個區網放一塊 ESP8266。它開機後主動連上 MQTT broker,訂閱一個 topic。本站要喚醒 UAP 時,只要往這個 topic 發一則訊息;板子收到後,就在區網裡廣播 magic packet。板子耗電不到 1W,常駐開著也不心疼,那台小主機就可以照平常習慣關機了。
本站(waterfalls)
│ ① 發佈喚醒訊息(帶 token)
▼
MQTT broker
│ ② 轉送給訂閱者
▼
ESP8266 中繼板(開機後主動連出去訂閱)
│ ③ 在區網廣播 magic packet
▼
UAP-server
只認正主:濾除假冒與非正規的喚醒訊息
本站藉由 MQTT 發佈,穿過這層「外面連不進來」的網路,讓區網裡的 ESP8266 收到「喚醒時機」。然而這則訊息是公開的,一旦被第三方照抄重送,真正的喚醒時機就可能淹沒在一堆假冒的喚醒時機裡。因此,要過濾出真正由本站發出的那一則,我們還需要一個只有雙方知道的憑據:token。
- token 怎麼放進板子。走區網內的 HTTP,不經過外網。直接用 codebase 原本就有的
/upload指令,把一組 32 位的隨機 token 存進板子的檔案系統,本站同時留一份。 - 喚醒時。本站把 token 當成 MQTT 訊息的內容發出去。板子拿來和自己存的那組比對,完全相符才送出 magic packet,同樣連送 5 次、間隔 3 秒。
- token 只在成功喚醒後才換新。
喚醒成功後,UAP 下次關機時會跑一支小程式:產生新 token,先經由 UAP 自己那條 reverse SSH 通道交給本站,再透過區網寫進板子。(2026-09-29 更正)改為由本站負責:喚醒成功、UAP 一上線,本站就經由 UAP 那條 reverse SSH 通道,借 UAP 在區網內把新 token 寫進板子。UAP 關機就是單純關機,不必再掛任何關機程式。token 只在喚醒那一刻出現在 MQTT 上,上線即換,外露的時間也更短。在成功喚醒之前,token 就算被人冒用也無妨,因為冒用的結果頂多也是把 UAP 叫醒,而這本來就是我們的目的。 - 為什麼 token 不讓 ESP8266 自己產生?板子能對外說話的管道只有 MQTT,而 MQTT 上的內容別人也看得到。區網和本站之間唯一的私密管道,是 UAP 開機時才存在的那條 reverse SSH 通道,
所以換 token 要趁 UAP 關機前、通道還在的時候做。所以換 token 要趁 UAP 開著、通道還在的時候做,現在就是一上線就換。 交接順序:先交給本站,再寫進板子。交接順序:本站先留底,再寫進板子,板子確認寫入才正式換用。萬一中途失敗,最壞也只是雙方繼續用舊的 token;就算板子寫進去了、本站卻沒收到確認,下次喚醒時新舊兩組都會送出,板子只認它手上那組,不會發生「板子換了新的、本站卻不知道」而叫不醒的情況。
大量非正規訊息湧入時
- 板子把 token 放在記憶體裡比對,每則訊息都比,一則都不漏,也不必每次都去讀檔案系統。
- 成功喚醒後有 60 秒冷卻時間,同一組 token 重複送來也不會一直發 magic packet。
- 被拒絕的訊息只計數,每分鐘彙整記錄一次。
實測中,每秒灌進上千則非正規訊息(連同一則正規訊息),板子都不會斷線,灌完 1 秒內就能正常喚醒。不過訊息量大到這個程度時,傳送途中本身就可能掉訊息,所以本站的喚醒腳本會連送 3 輪、每輪間隔 5 秒,這樣實測下看來就萬無一失了。
韌體:延伸既有的 codebase
韌體以 Ken 先前的〈The Most Practical Codebase for ESP8266〉為基礎,新增的功能都用編譯開關包起來,不影響 codebase 原本的行為:
- MQTT 函式庫:用 AsyncMQTT_Generic 搭配 ESPAsyncTCP,也就是〈MQTT & Websocket〉那篇驗證過的組合。
- 計時與排程:沿用 codebase 內建的
Timeafter()。 - 中繼板模式:預設以 STA 模式連上分享器;並關掉 codebase「每開機 10 次強制進 AP 模式」的救援機制,因為沒人照顧的板子一旦進了 AP 模式,就會一直斷線到下次斷電為止。
- 私密值另外存放:WiFi 帳密、topic 名稱等放在另一個不公開的標頭檔,原始碼本身不含任何祕密。
- 更新方式:板子部署後沒有接電腦,當然就無法做 com port 有線更新,只能靠 codebase 內建的 OTA 更新。上線前已經實測過多次,更新後檔案系統裡的 token 都還在。
這些改動會累積到之後的 codebase 改版一起發布。
(2026-09-29 更正)完整程式直接附在這裡。它的定位是「以 codebase v1.3 為基礎的應用」,不是 codebase 的新版本;codebase 本身的改版另外再發布。下載後只有兩個檔案:
tmpc_esp8266.ino:codebase v1.3 原檔(cDataBase、cAlarm、Timeafter 等都在這一支裡),加上 WoL 中繼的部分。tmpc_secrets.h:私密值(WiFi 帳密、broker、topic、client ID、目標網卡 MAC),附的是中性佔位文字,換成自己的值即可編譯。
相對於 codebase v1.3,異動只有這些(檔頭的修改記錄也逐條列出):
- 新增
WOL_RELAY_SUPPORT區塊(約 220 行,編譯開關包住):MQTT 連線與訂閱、token 比對、用Timeafter()連送 magic packet、大量訊息湧入時的處理。 - 新增
TMPC_USE_SECRETS:私密值改由tmpc_secrets.h引入。 - 開啟 WoL 中繼時,跳過「每開機 10 次強制進 AP 模式」的救援機制。
- 檔頭加上這個應用的說明,以及尚未處理的 todo:OTA 沒有帳密、會寫入的 HTTP 指令沒有檢查金鑰、1883 是明文、WiFi 連線是阻塞式等待、同一 SSID 無法多組密碼輪替。
本站的兩支腳本
以下是精簡後的版本,邏輯與實際運行的相同;私密值以 <…> 佔位。先把目前的 token 存在 token、topic 存在 topic(權限 600)。喚醒:
#!/bin/bash
# wake_uap.sh -- 經 MQTT 叫 ESP8266 中繼板喚醒 UAP;UAP 一上線就換新 token
set -u
DIR=$HOME/wol
BROKER=<MQTT broker>
TOPIC=$(cat "$DIR/topic") # public/ 底下一個不具描述性的隨機字串
UAP=<UAP 的 ssh 連線名稱> # 走 UAP 自己連回本站的 reverse SSH 通道
# 連送 3 輪、間隔 5 秒;QoS 1、不設 retained
# token.new 是上次換 token 沒走完留下的,一併送(板子只認其中一組)
for round in 1 2 3; do
for f in "$DIR/token" "$DIR/token.new"; do
[ -r "$f" ] && mosquitto_pub -h "$BROKER" -p 1883 -q 1 -t "$TOPIC" -m "$(head -1 "$f")"
done
[ $round -lt 3 ] && sleep 5
done
# 等 UAP 上線(最多 150 秒),一上線就換 token
for i in $(seq 15); do
timeout 8 ssh -o ConnectTimeout=6 "$UAP" true 2>/dev/null && exec "$DIR/rotate_token.sh"
sleep 10
done
echo "150 秒內未上線,token 不換(可再跑一次)"
exit 2
換 token(喚醒腳本在 UAP 上線後會自動呼叫):
#!/bin/bash
# rotate_token.sh -- 產生新 token,經 UAP 在區網內用 codebase 的 /upload 寫進板子;
# 板子回 303 才正式換用,失敗時雙方續用舊 token
set -u
DIR=$HOME/wol
UAP=<UAP 的 ssh 連線名稱>
BOARD=<板子的區網位址或 mDNS 名稱>
umask 077
new=$(python3 -c 'import secrets; print(secrets.token_hex(16))')
printf '%s\n' "$new" > "$DIR/token.new" # 先留底,萬一只寫進板子卻沒收到回應也不會失聯
code=$(printf '%s\n' "$new" | timeout 60 ssh "$UAP" \
'f=$(mktemp); cat > $f
curl -s -m 10 -o /dev/null -w "%{http_code}" -F "name=@$f;filename=/wol_nonce.txt" http://'"$BOARD"'/upload
rm -f $f')
if [ "$code" = 303 ]; then
mv "$DIR/token.new" "$DIR/token"
echo "token 已更新"
else
echo "寫入失敗(HTTP $code),續用舊 token"
exit 1
fi
實測結果
- 透過 MQTT 實際喚醒 UAP,30~40 秒內上線。
- 原本打算讓板子吃 UAP 的 USB 電,實測才發現 UAP 關機後 USB 就斷電了,所以改插獨立的 USB 充電頭。
長時間關機後的喚醒測試:UAP 關機 1、2、4、8 小時後都成功叫醒,更長時間的測試還在進行中。長時間關機後的喚醒測試:第一輪 UAP 關機 1、2、4、8、16 小時後都成功叫醒。改成「上線即換 token」之後重新測第二輪:10 分鐘成功(31 秒上線、2 秒換好 token),1.5、24、72 小時還在進行中。
踩到的坑
- 同一個 SSID 設兩組密碼,並不會換下一組。本想在更換 WiFi 密碼時順便測試看看,讓新舊兩組並存來過渡,翻了 ESP8266 core 裡
ESP8266WiFiMulti.cpp的原始碼才知道:它挑網路時比的是訊號強度,而且要「嚴格大於」目前的最佳值才會換。同一個 SSID 的第二筆因為訊號強度相同,永遠不會被選上,實測也是一路拿第一組去連。只有 SSID 不同時,它才會換下一組。 - WiFi 連線是阻塞式等待。連不上的期間,整個主迴圈都停住,連 LED 都不會閃。所以 LED 不亮,不一定代表沒電。
- 打開序列埠,板子就被重開。即使先把 DTR、RTS 設成關閉再打開 port,還是會重開。改用下面的方式設定後,純讀取就不會:
stty -F /dev/ttyUSB0 115200 raw -echo -hupcl
各參數的意思:
-F /dev/ttyUSB0:指定要設定的序列埠裝置。115200:鮑率,要和板子程式裡Serial.begin()的設定一致。raw:原始模式,收到什麼就交出什麼,不做換行轉換、也不把特殊字元當控制指令。-echo:不把收到的字元回送給板子。-hupcl:關鍵在這一項。關閉序列埠時不拉動 DTR/RTS 控制線;而這類開發板正是用這兩條線來觸發重開機(和進入燒錄模式)。
設定好之後,程式只以唯讀方式開啟序列埠、不去碰控制線,監看時就不會把板子重開。
- 在訊息處理函式裡動態配置記憶體。早期版本每拒絕一則訊息,就排一個需要配置記憶體的工作。在大量訊息湧入時,記憶體很快被吃光,改成同一時間只排一個之後就解決了。
小結
這套 WoL 用 MQTT 把「喚醒時機」送進外面連不進來的區網,由 ESP8266 在區網裡喊醒 UAP,再用 reverse SSH 私下遞交 token,確保只有正主發出的訊息算數。各司其職,也算是 Wake-on-LAN 的另類玩法。