上一篇〈Pi 5 雙 WiFi × ESP32〉裡,ESP32 連的是 Pi 5 開的熱點(10.42.0.x),MQTT broker 卻在另一台主機 gx10 上,而且 broker 位址填的是 gx10 的 Tailscale IP。ESP32 根本沒裝 Tailscale,這條路是怎麼通的?
這篇把那一行「經 Pi NAT → Tailscale」拆開,用 Pi 5 上實際抓下來的路由表、防火牆規則,一步一步追一個 MQTT 封包從 ESP32 走到 gx10 再走回來的完整路徑。順便解釋當初那個怪現象:為什麼 MQTT 通、但 ESP32 上不了網。
一、角色與位址
| 角色 | 介面 | 位址 |
|---|---|---|
| ESP32 | WiFi(連 Pi 熱點) | 10.42.0.170,閘道 10.42.0.1 |
| Pi 5 | wlan1(USB 網卡,熱點) | 10.42.0.1/24 |
| wlan0(內建,公司 WiFi) | 192.168.1.236 |
|
| tailscale0 | 100.108.160.111 |
|
| gx10 | LAN | 192.168.1.25 |
| tailscale0 | 100.69.83.97(mosquitto :1883 在 Docker 裡) |
(100.x 是 Tailscale 的 CGNAT 位址,只有同一個 tailnet 的裝置才連得到,公開出來沒有風險。)
二、全貌
ESP32 10.42.0.170:5xxxx ──WiFi──▶ Pi5 wlan1 (10.42.0.1)
目的 100.69.83.97:1883 │
│ ① 路由:查 table 52 → 走 tailscale0
│ ② 轉發過濾:ts-forward 放行
│ ③ 來源 NAT:10.42.0.170 → 100.108.160.111
▼
tailscale0 → tailscaled(WireGuard 加密)
│ 包成 UDP,從 wlan0 送出(同區網直連)
▼
gx10 tailscale0 → docker-proxy → mosquitto 容器
三、第 0 步:ESP32 把封包丟給閘道
ESP32 從熱點的 DHCP(NetworkManager 內建的 dnsmasq)拿到 10.42.0.170,預設閘道 10.42.0.1。它要連 100.69.83.97:1883,這不在自己網段,於是交給閘道 Pi 5。
重點:ESP32 完全不知道 Tailscale 存在,對它來說 100.69.83.97 就只是一個「外面的 IP」。
四、第 1 步:Pi 5 決定往哪送(路由)
NetworkManager 的 ipv4.method shared 會打開 ip_forward,Pi 5 開始幫別人轉送封包。接著查 policy routing 規則:
$ ip rule
0: from all lookup local
5210: from all fwmark 0x80000/0xff0000 lookup main
5230: from all fwmark 0x80000/0xff0000 lookup default
5250: from all fwmark 0x80000/0xff0000 unreachable
5270: from all lookup 52 ← Tailscale 的路由表,優先查
32766: from all lookup main ← 一般路由表(default 走 wlan0)
32767: from all lookup default
$ ip route show table 52 | grep 100.69.83.97
100.69.83.97 dev tailscale0
Tailscale 會把 tailnet 裡每一台節點都寫進 table 52,並用優先序 5270 的規則讓它比 main 表先被查到。(5210–5250 那幾條帶 fwmark 0x80000 的,是給 tailscaled 自己送出去的 WireGuard UDP 用的,避免它被繞回 tailscale0 形成迴圈。)
用 ip route get 模擬「從 wlan1 進來、來源是 ESP32」的封包,差異一目了然:
$ ip route get 100.69.83.97 from 10.42.0.170 iif wlan1
100.69.83.97 from 10.42.0.170 dev tailscale0 table 52 ← 走 Tailscale
$ ip route get 1.1.1.1 from 10.42.0.170 iif wlan1
1.1.1.1 from 10.42.0.170 via 192.168.1.1 dev wlan0 ← 一般上網走 wlan0
目的地是 tailnet 節點 → tailscale0;其他 → wlan0。這一步是自動的,不需要任何設定。
五、第 2 步:轉發過濾(兩張表都要放行)
這台 Pi 上同時有兩套防火牆在看 FORWARD:NetworkManager 自己建的 nftables 表,以及 Docker/Tailscale 透過 iptables-nft 管的 filter 表。封包必須通過每一個 forward hook,任何一個 DROP 就沒了。
NetworkManager 的表(shared 模式自動建立):
table ip nm-shared-wlan1 {
chain filter_forward {
type filter hook forward priority filter; policy accept;
ip daddr 10.42.0.0/24 oifname "wlan1" ct state { established, related } accept
ip saddr 10.42.0.0/24 iifname "wlan1" accept ← ESP32 出去的封包:✅
iifname "wlan1" oifname "wlan1" accept
}
}
Docker + Tailscale 的 iptables FORWARD:
$ sudo iptables -S FORWARD
-P FORWARD DROP ← Docker 把預設政策改成 DROP
-A FORWARD -j DOCKER-USER
-A FORWARD -j DOCKER-ISOLATION-STAGE-1
...(docker0 相關)
-A FORWARD -j ts-forward
$ sudo iptables -S ts-forward
-A ts-forward -i tailscale0 -j MARK --set-xmark 0x40000/0xff0000
-A ts-forward -m mark --mark 0x40000/0xff0000 -j ACCEPT
-A ts-forward -s 100.64.0.0/10 -o tailscale0 -j DROP
-A ts-forward -o tailscale0 -j ACCEPT ← 往 tailscale0 的封包:✅
ESP32 的封包要出 tailscale0,在 ts-forward 最後一條被 ACCEPT。前一條 -s 100.64.0.0/10 -o tailscale0 -j DROP 是防偽造 Tailscale 來源用的,我們的來源是 10.42.x,不受影響。
解謎:為什麼「MQTT 通、上網不通」
| 目的地 | 出口 | NM 表 | iptables FORWARD | 結果 |
|---|---|---|---|---|
| gx10 的 100.x(MQTT) | tailscale0 | ✅ | ✅ ts-forward 放行 |
通 |
| NTP / 外網 | wlan0 | ✅ | ❌ 沒有規則認領,掉進 -P DROP |
不通 |
MQTT 是「剛好」被 Tailscale 的規則救了。解法是在 Docker 保留給使用者的 DOCKER-USER 鏈補上 wlan1 ↔ wlan0 的放行(細節見上一篇第六節)。
六、第 3 步:來源 NAT(masquerade)——整條路的關鍵
封包要離開 Pi 5 之前,經過 NAT 的 postrouting:
table ip nm-shared-wlan1 {
chain nat_postrouting {
type nat hook postrouting priority srcnat; policy accept;
ip saddr 10.42.0.0/24 ip daddr != 10.42.0.0/24 masquerade
}
}
這條規則不管出口是哪張網卡,只要來源是熱點網段、目的不是熱點網段就 masquerade。出口是 tailscale0,所以來源被改成 tailscale0 的位址:
改寫前:10.42.0.170:5xxxx → 100.69.83.97:1883
改寫後:100.108.160.111:yyyy → 100.69.83.97:1883
為什麼一定要這步?
- gx10 不知道 10.42.0.0/24 在哪。來源若保持 10.42.0.170,gx10 回封包時查不到路由。
- Tailscale 本身也會擋:WireGuard 的 AllowedIPs 機制下,gx10 只接受「來自 Pi 5 這個 peer、來源位址是 Pi 5 自己 100.x(或它有公告的子網段)」的封包。10.42.0.170 兩者都不是,會直接被丟掉。
改成 100.108.160.111 之後,在 gx10 眼中這就是 Pi 5 自己發起的連線,一切合法。Linux 的 conntrack 記下對應 100.108.160.111:yyyy ⇄ 10.42.0.170:5xxxx,回程時用得到。
附帶一提,Tailscale 自己也有一條 masquerade(ts-postrouting),但它只處理帶 0x40000 標記、也就是「從 tailscale0 進來再轉出去」的封包,跟我們這個方向無關;真正做事的是 NetworkManager 那條。
七、第 4 步:WireGuard 加密,從 wlan0 出門
tailscale0 是 TUN 虛擬網卡,封包寫進去後由 tailscaled 用 WireGuard 加密,包成 UDP(預設 41641 埠),再從實體網卡送出。這個 UDP 封包本身帶著 fwmark 0x80000,所以會命中前面 5210 那條規則、查 main 表從 wlan0 出去,而不是又繞回 tailscale0。
因為 Pi 5 和 gx10 剛好在同一個公司區網,Tailscale 會打通區網直連(192.168.1.236 → 192.168.1.25:41641),不經過外部 DERP 中繼,延遲只有幾 ms。確認方式:
$ tailscale ping gx10-6679
pong from gx10-6679 (100.69.83.97) via 192.168.1.25:41641 in 5ms ← "via 區網IP" 就是直連
八、第 5 步:gx10 收件
gx10 的 tailscaled 解密後,從 tailscale0 吐出 100.108.160.111 → 100.69.83.97:1883。1883 是 Docker 發佈的埠(0.0.0.0:1883->1883/tcp),經 docker-proxy 轉進 mosquitto 容器。所以在容器裡看,連線來源是 Docker 閘道 172.17.0.1:
$ docker exec mosquitto netstat -tn | grep 1883
tcp 0 0 172.17.0.2:1883 172.17.0.1:60584 ESTABLISHED
經過兩層轉換(Pi 的 masquerade + docker-proxy),broker 已經看不出這是 ESP32 了;要辨識裝置,得靠 MQTT 的 client ID(esp32-48cfe4)。
九、回程
- mosquitto 回覆 → docker-proxy → gx10 tailscale0,目的
100.108.160.111。 - WireGuard 加密,UDP 直連回 Pi 5 的 wlan0,tailscaled 解密後從 Pi 的 tailscale0 吐出。
- conntrack 查到對應,把目的地還原成
10.42.0.170:5xxxx。注意 NAT 規則只在連線第一個封包時評估,回程不會再跑一次 masquerade。 - 路由:10.42.0.0/24 是 wlan1 直連網段 → 從 wlan1 送出。
- 轉發過濾:NM 表的
ct state established放行;iptables 那邊,ts-forward對「從 tailscale0 進來」的封包打上0x40000標記後 ACCEPT。 - ESP32 收到 MQTT 回應。
十、這個設計帶來的三個特性
1. 單向:ESP32 能找 gx10,gx10 找不到 ESP32
gx10(以及 tailnet 裡任何機器)的路由表裡沒有 10.42.0.0/24,無法主動連 ESP32。所以 ESP32 的 HTTP API(:8080)、WebSocket(:81)、OTA(:3333)都只能在 Pi 5 上操作。MQTT 剛好是「裝置主動連 broker」的模型,天生適合這種單向架構——指令也是 ESP32 自己去 broker 訂閱拿回來的。
2. 熱點裡的裝置等於能碰到整個 tailnet
table 52 裡的每個 100.x 都會被轉進 tailscale0,而且來源被改成 Pi 5 的身分。換句話說,任何連上這個熱點的裝置,都能以 Pi 5 的名義連到你所有 Tailscale 機器。目前熱點只有自己的 ESP32、密碼是隨機 16 字元的 WPA2,風險可控;但如果要給不信任的裝置用,應該在 forward 加規則,限制熱點只能連特定的 IP:port(例如只放行 gx10:1883)。
3. 想要雙向:用 Subnet Router
如果希望 gx10 或筆電能直接打 ESP32 的 HTTP API、甚至用 Arduino IDE 直接 OTA,正規做法是讓 Pi 5 把熱點網段公告進 tailnet:
# Pi 5 上
sudo tailscale set --advertise-routes=10.42.0.0/24
# 然後到 Tailscale 管理後台核准這條路由;
# Linux 用戶端要 tailscale set --accept-routes,Windows/macOS 預設接受
這樣其他 tailnet 節點會在自己的 table 52 裡多一條 10.42.0.0/24 → Pi 5,連線就能雙向。比起在筆電手動加靜態路由、把熱點開放給整個公司區網,這個做法只對自己的 Tailscale 裝置開放,安全很多。
十一、小結
- 路由:Tailscale 的 table 52(優先序 5270)讓 100.x 目的地自動走 tailscale0,不用設定。
- 過濾:同時有 NM 的 nftables 表和 Docker/Tailscale 的 iptables 表,兩邊都要放行;Docker 的
FORWARD DROP只會讓走 wlan0 的流量出事。 - NAT:NM shared 的 masquerade 不挑出口,把 10.42.x 換成 Pi 5 的 100.x 身分,這是 Tailscale 願意收這個封包的前提。
- 傳輸:WireGuard over UDP,同區網會直連,延遲幾 ms。
- 代價:單向,而且熱點裝置繼承了 Pi 5 在 tailnet 裡的存取範圍。
排查類似問題時,最好用的三個指令:ip route get <目的> from <來源> iif <入口> 看路由、iptables -S + nft list ruleset 看過濾和 NAT、tailscale ping 看是不是直連。

沒有留言:
張貼留言