我維護一套只做一件事的家用網路基礎設施:讓家裡每一台裝置都能透明地走分流上網。境內流量直接出去,其他一律經過 VPS、走一條偽裝過的加密隧道(具體來說是 VLESS 搭配 Reality 協定)。任何裝置上都不需要安裝 VPN App、不用設定代理、不用帳號密碼——接上 Wi-Fi 就該直接能用。理論上是這樣。

從「在筆電上的 Docker 模擬環境裡跑得通」到「在真正的路由器硬體上跑得通,給家人用、不用我隨時盯著」,花的時間遠比我預期的長,而且幾乎沒有一個踩坑點是我事先準備好要面對的。這篇是誠實版的故事——那顆對自己指令集撒謊的核心、文件裡預設你一定會有、但實際上這台硬體怎麼樣都生不出來的防火牆功能,還有一個我完全沒放進考慮範圍的協定家族,直到瀏覽器的錯誤訊息直接把矛頭指過去為止。

系統的基本形狀

三個部分:

  • 一台伺服器sing-box,用 VLESS inbound 包上 Reality——一種刻意把連線偽裝成連到某個真實網站(我用的偽裝目標是 www.bing.com)的一般 HTTPS 流量的協定。伺服器擺在 Nginx 後面,Nginx 在 443 port 上用 SNI 做分流,這樣才能跟這台機器上原本就有的其他服務共存。
  • 一套 Docker 測試環境——兩個容器,一個「router」、一個假的「device」,接在一起模擬整個拓撲,在筆電上一分鐘內就能跑完一輪。任何改動都先在這裡驗證。
  • 真正的部署目標:家裡的路由器(跑 ASUSWRT-Merlin 韌體的 ASUS 機型),真正攔截並轉發家中每一台裝置的流量。

到目前為止的原則是:任何改動沒有先過 Docker 模擬這一關,就不准碰真正的路由器。這個紀律完全值回票價——但也留下一個盲區,這篇文章大部分內容就是在講這個盲區。

第一個意外:核心不知道自己的指令集

sing-box 針對不同架構發布預編譯的執行檔。這台路由器的核心用 uname -a 回報自己是 aarch64(64 位元 ARM),所以我理所當然抓了 linux-arm64 版本。下載沒問題、解壓縮也沒問題,跑起來直接報錯:

error while loading shared libraries: libdl.so.2: cannot open shared object file

那個函式庫確實存在,只是它是 32 位元版本:

error while loading shared libraries: libdl.so.2: wrong ELF class: ELFCLASS32

這台路由器的核心是 64 位元,但整個 userland——每一個共享函式庫、每一個系統執行檔——全部都是 32 位元 ARM。這在 Broadcom 系的路由器 SoC 上其實不算罕見:廠商 SDK 跟驅動 blob 往往是以 32 位元形式出貨,就算核心後來換成 64 位元編譯,也從來沒有跟著重新編譯過。uname -m 誠實地告訴你核心的狀況,卻完全不會告訴你它承載的 userland 是什麼樣子。

修法很簡單——改抓 linux-armv7 版本——但如果沒有實際去確認系統既有函式庫真正的 ELF class,光憑核心自己回報的架構去猜,是不會找到真正原因的。

第二個意外:文件預設你一定會有的功能

sing-box 針對這種情境最方便的功能是 auto_redirect:把 tun inbound 指向它,它就會自動裝好所有需要的核心層規則,透明攔截區網內其他裝置的流量、套用你的路由規則,完全不用手動寫防火牆規則。

它馬上就失敗了:

FATAL start service: post-start inbound/tun: auto-redirect: missing nftables support: netlink receive: invalid argument

nft(nftables 的 CLI 工具)不存在——這部分我本來就有心理準備,嵌入式韌體常有這種狀況。沒想到的是:核心模組(nf_tables.ko)其實存在於這台韌體上,而且載入時完全不會報錯。只是它沒有用。載入之後,sing-box 會再往前走一步,然後用另一種方式失敗:

FATAL auto-redirect: setup nftables: create ipv4 local address set: netlink receive: operation not supported

去查核心自己的編譯設定證實了這件事:CONFIG_NF_TABLES_IPV4 從來沒有被編譯進去。通用的 nftables netlink socket 是存在的(足以通過第一輪的握手),但背後完全沒有 IPv4 table 的實作。而且關鍵是:我當時跑的這個版本的 sing-box,auto_redirect 完全沒有 iptables 的退路。要嘛用 nftables,要嘛什麼都沒有,而這台硬體永遠只能提供「什麼都沒有」。再怎麼 modprobe,都補不回一個從沒被編進核心裡的功能。

到這裡我就放棄嘗試讓這條方便的路徑跑起來,轉而自己動手做它原本該幫我自動化的那套機制。

手工打造:TPROXY

在 Linux 上,不碰任何一台裝置的設定就能透明攔截流量的經典做法是 TPROXY——一個非常老牌、支援度很好的 iptables/ip6tables target(在這台路由器上確認可用:載入 xt_TPROXY.ko 會自動一併拉進 nf_tproxy_ipv4/nf_tproxy_ipv6,完全不需要 nftables)。概念上:

  1. mangle table 裡的一條規則掛在 PREROUTING,攔截從區網介面進來的流量,重新導向到本機一個正在監聽的 socket,同時保留封包原本的目的地,讓接手處理的程式仍然知道這個封包本來要去哪。
  2. sing-box 單純的 tproxy inbound 型別就是為此而生——不需要 tun 裝置、不需要 auto_route,就是一個接受「已經被標記、已經被導向」連線的 socket,套用跟其他情境完全一樣的路由規則(依網域、依 IP 分類,決定走直連還是走代理)。
  3. 一條 policy routing 規則(ip rule 加上一張專用的路由表)告訴核心:帶有特定 fwmark 的封包要在本機被送達,而不是繼續往外轉發——這正是讓 TPROXY「假裝這是給我的」這個把戲真正生效的關鍵一步。

規則組大致長這樣(實際版本還多了幾條私有/保留網段的排除):

iptables -t mangle -N SB_TPROXY
# DNS:即使是直接打到路由器自己 DNS 的查詢也一律攔截,
# 這樣境內/境外分流的 DNS 邏輯才能保持一致的權威來源。
iptables -t mangle -A SB_TPROXY -p udp --dport 53 -j TPROXY \
    --on-port 7893 --on-ip 127.0.0.1 --tproxy-mark 0x1/0xffffffff
# 打到路由器自己的流量(SSH、管理介面):放行不處理。
iptables -t mangle -A SB_TPROXY -m addrtype --dst-type LOCAL -j RETURN
# 私有/保留網段:放行不處理,讓區網內部的流量保留真正的來源位址,
# 而不是被 sing-box 的 direct outbound 從路由器重新發起連線。
iptables -t mangle -A SB_TPROXY -d 10.0.0.0/8 -j RETURN
# ...(其他 RFC1918/保留網段)
# 其餘全部攔截。
iptables -t mangle -A SB_TPROXY -p tcp -j TPROXY \
    --on-port 7893 --on-ip 127.0.0.1 --tproxy-mark 0x1/0xffffffff
iptables -t mangle -I PREROUTING 1 -i br0 -j SB_TPROXY

ip rule add fwmark 0x1 lookup 100 priority 100
ip route add local 0.0.0.0/0 dev lo table 100

這組規則跑起來——大概跑了十分鐘,直到我仔細一看,發現 table 100 其實根本不是空的。

第三個意外:你以為「明顯沒人用」的號碼,通常不是

ip route show table 100 顯示出來的內容是掛在 wan0 這個名字底下,不是數字 100。原來這台韌體的 /etc/iproute2/rt_tables 早就保留了一整段低位數字給路由器自己用:1 到 10 給 VPN 用戶端設定檔(WireGuard/OpenVPN client),100 跟 200 給它自己的雙 WAN 功能。我挑的這個號碼「看起來」沒人用(當時 ip rule show 裡也確實沒有任何規則指向它),但實際上它是廠商自己韌體子系統隨時可能開始寫入的一個預先命名好的位置。

兩個教訓,現在都已經寫進實際的腳本裡:

  • table ID 要從部署當下就已經是唯一的東西去推導(我用的是 tproxy 監聽的 port 號),而不是隨手挑一個「看起來沒人用」的小整數——網路上每一篇「怎麼加一張路由表」的教學文,第一個直覺也都是挑這種數字。
  • 檢查規則是否已存在時,絕對不要用數字 ID 去做字串比對——iproute2 只要該 ID 有對應的符號名稱,顯示時就會靜靜地用名稱取代數字,所以 grep "lookup 100" 可能永遠比對不到,讓清除邏輯誤以為沒東西要清。改成比對 fwmark 本身;這個不會被命名表牽連。

我最在意的部分:不能把自己鎖在門外

到這個階段,防火牆設定顯然還會繼續迭代——而每一次迭代,都是對家裡唯一連到網際網路的那台裝置推送一次即時變更。搞砸了不是「代理壞掉」而已,而是「全家人斷網,然後我要想辦法解釋為什麼」。

所以在真正拿這套東西上線之前,我先做了網路工程師處理高風險遠端變更時會用的同一套模式:commit confirmed(先套用、再確認)

  • start 會在動手之前,先把目前的 iptables/ip6tables/policy routing 狀態拍照存下來,然後啟動一個背景計時器。
  • 如果沒有人在時限內(我設三分鐘)執行 confirm,路由器會自己動手——不需要任何人開著 SSH session——砍掉程序,並把狀態還原成拍照當下的樣子。
  • 手動執行 revert 也會立刻做一樣的事,從任何還連得上路由器的裝置都能觸發。
  • 而這一切之下還有最後一道防線:只要從來沒有告訴路由器要在開機時自動啟動這套東西,直接拔插座重開機就是一個無條件的重置,因為沒有任何東西活在 JFFS flash 或 services-start 裡——全部都是活在核心記憶體裡的暫時狀態,重開機就免費幫你清乾淨。

在真正信任這套機制之前,我特意去試著弄壞它:手動插入一條假的防火牆規則、一條假的 policy routing 規則、還有一張孤兒路由表——刻意模仿真正的 TPROXY 設定實際上會建立出來的東西——然後跑一次還原流程,把結果拿去跟已知正確的基準狀態做逐位元組比對。完全吻合。這次演練也抓到一個真正的 bug:我第一版的清除邏輯,是先刪掉某條孤兒 policy rule,才去檢查它原本指向哪張路由表——導致那張現在已經沒人指向的表永遠不會被清掉。當你要「拿現在的狀態跟之前的狀態做差異比對」時,順序很重要:要在刪除任何東西之前,先把所有即將被刪除的東西的「之前」狀態記下來。

第四個意外:我忘了整整一個位址家族的存在

以上全部都是 IPv4 的故事。這套系統穩定跑了好幾天。然後收到一個乍看完全無關的回報:某台筆電的瀏覽器對一個網域回報 ERR_CONNECTION_CLOSED,而幾秒鐘前同一個網域在另一台裝置上明明正常。

除錯過程繞了幾個彎路——一開始先卡在瀏覽器自己的加密 DNS 設定覆蓋了網路本身的解析器(這是另一個真實存在、值得知道的坑,但不是這次的元兇)——直到真正的破案關鍵,用最直白的方式浮出水面:直接在出問題的機器上跑 nslookup,看它實際上用了哪一台 DNS 伺服器。

結果不是路由器的 IPv4 位址,是路由器的 IPv6 位址。

這個網路會給每一台裝置分配真正的、可全球路由的 IPv6 位址——不需要 NAT,因為 IPv6 本來就不需要。現代作業系統越來越傾向在有 IPv6 可用時優先使用(macOS 的「Happy Eyeballs」演算法就是其中一個例子),這代表一台裝置預設就有可能把 DNS 查詢——甚至實際的資料連線——直接走 IPv6 送出去。我整套完全只用 iptables 打造的 TPROXY 設定,對這些流量來說完全隱形。它直接穿過我寫的每一條規則,打中路由器上那個沒有任何保護的 DNS 解析器,拿回一個「某個國家級防火牆不喜歡的網域」在沒有保護的解析器上會拿到的答案——這個答案不見得會直接報錯,只是一個錯誤或被降級的結果,看起來卻剛好足以讓瀏覽器嘗試連線、失敗、然後怪罪到網路頭上。

修法是 IPv4 設定的鏡像版本——把 iptables換成 ip6tables、多加一個綁在 ::1tproxy inbound、沿用同一組 fwmark 跟 table ID(事實證明 IPv4 跟 IPv6 的 policy routing 在核心層是完全獨立的命名空間,所以號碼共用完全沒問題)——再加上一個 IPv4 從來不需要處理的全新麻煩:

IPv4 有 RFC1918 私有網段可以自然地當作「這是區網內部流量,別碰」的訊號。IPv6 沒有 NAT,也就沒有對應的東西——每一台裝置的位址都是全球可路由的,私有 vs 公開在 IPv6 的世界裡不是一個有意義的區分。同一個家用網路裡兩台裝置之間的流量,仍然需要被排除在攔截範圍之外(不然會被沒必要地重新導入隧道,破壞區網內部流量原本該有的來源位址語意),這代表需要明確排除掉這個區網自己被分配到的那個 IPv6 網段。而且這個網段不能寫死:ISP 分配的 IPv6 prefix 在續約時是可能改變的,所以防火牆腳本改成直接從核心自己的路由表即時讀取,而不是把一個遲早會過期的值寫死進去。

這一個我沒有只靠模擬測試就收工,而是直接拿真實流量驗證:親眼看著新的 IPv6 防火牆鏈的封包計數器,因為某台裝置原本就有的背景流量而真的往上跳,然後在程式的 log 裡確認同一條連線確實被正確分類、正確轉發進了隧道。沒有什麼比親眼看到真的東西動起來,更讓人放心了。

第五個意外:重開機才是真正的期末考

以上所有東西穩定跑了一陣子之後,我開始處理最後一塊:讓路由器重開機後這套系統能自己活過來,而不是每次跳電或韌體升級後都要我人工上去手動啟動。

第一直覺是「裝上開機腳本不就好了」,但仔細一想,光是這樣做完全沒用。前面的 commit-confirmed 安全機制裡,start 永遠會先武裝一個三分鐘的看門狗計時器,等一個人手動執行 confirm。開機自動啟動的情境下,根本沒有人在看——如果照搬同一套 start 邏輯,結果就是每次開機都武裝計時器、三分鐘後沒人確認、自動還原,然後又回到「什麼都沒啟動」的狀態,完全沒解決問題,只是把同一個死結搬到每次開機都要重演一次而已。

解法是另外做一個 boot-start 指令,開機腳本呼叫這個而不是 start:一樣先啟動、武裝看門狗,但接著自己跑一個小型的自我檢測——因為路由器自己發出的流量不會被 TPROXY 攔截(我們的規則只作用在從區網介面 br0 進來的流量),沒辦法直接測試「區網裝置的流量有沒有被正確攔截並轉發」這件事本身,退而求其次,改成用一個原始 TCP 連線去確認路由器到 VPS 本身是不是打得通。打得通就自動執行 confirm;打不通就放著讓看門狗照原訂時間自動還原——一次真正壞掉的開機,還是會在三分鐘後自己收斂回出廠設定的狀態,只是不是瞬間發生而已。

這套機制要做到真正可靠,中間經過兩次真實重開機才把兩個問題挖出來,而且兩個都不是光看文件猜得到的:

問題一:services-start 執行的時候,記憶卡根本還沒掛載好。 我是從系統日誌抓到證據的——services-start 執行的那一刻,系統時間顯示的是「1 月 1 日」,也就是時鐘都還沒對時,代表這個腳本跑在開機流程非常前面的位置,遠早於這台韌體完成 USB 讀卡機的列舉動作。修法是讓 boot-start 先輪詢等待 sing-box 執行檔真的出現在記憶卡上,才繼續往下做任何事。

問題二:WAN 連線也還沒好,而且這個比看起來嚴重得多。 我們的設定裡開了 route.auto_detect_interface,這代表 sing-box 只會在啟動的那一刻,一次性地偵測並綁定自己對路由環境的認知。如果在 PPPoE 連線(ppp0)還沒真正建立好、連一條預設路由都還沒有的時候就啟動,不是單純的「等一下就會自己好」的暫時性錯誤——而是會讓某些區網裝置的流量,在接下來整個執行期間都完全沒被 TPROXY 攔截到,卻沒有任何明顯的錯誤訊息或當機跡象,sb.sh status 照樣顯示一切正常。這個是我透過 conntrack 才抓到的:受影響裝置的連線,回應是透過路由器的 WAN NAT 位址回來的,而不是被 sing-box 直接本機應答——這正是「完全沒被攔截,走了正常直連」的訊號。而且詭異的是,同一台路由器上其他裝置完全正常,只有特定幾台受影響,一開始完全看不出規律。唯一有效的修法是後來手動下指令重開一次 sing-box,等網路真正穩定之後——那次之後,所有裝置立刻恢復正常。找到規律之後,修法跟第一個問題一樣:讓 boot-start 先輪詢等待出現一條真正的預設路由(外加一段緩衝時間),才真的啟動 sing-box。

這次的除錯過程本身也是一堂課:好幾個裝置的問題,一開始被我誤判成「這是我自己測試機上 Tailscale 搞的鬼」——因為我自己的測試機當時確實同時開著 Tailscale,也確實在某些測試裡製造出好幾次假警報。但等到用戶回報說已經把 Tailscale 整個關掉問題還在,才確定這是路由器端真實存在的問題,不是我自己測試環境的雜訊。同一個症狀,如果你手邊剛好有一台環境比較複雜的測試機,很容易把注意力導向錯誤的方向;跨裝置交叉驗證,而不是只信任手邊最方便的那一台,是這次唯一走出來的方法。

最後這套機制經過四次真實重開機驗證:記憶卡正確掛載、boot-start 在完全沒有人操作的情況下自我檢測並自動確認、多台真實家庭裝置在開機後幾分鐘內就被正確攔截並轉發進隧道,沒有一次需要我手動介入。

第六個(比較小的)意外:地理位置的影響比想像中大

跟上面這一整串韌體考古完全無關的另一件事:隧道另一端接的那台 VPS 在哪裡,對整套系統「用起來的感覺」影響很大,而這一點很容易被低估,直到你真的去量出數字為止。

一開始這套系統接的是美國東岸的一台 VPS。直接量測到那邊的真實網路往返時間(不是用 ping——很多雲端服務商的防火牆預設就會把 ICMP 悄悄擋掉,看起來很嚇人,其實什麼都不代表)大概是四分之一秒的單趟延遲。這個數字在一次網頁請求裡會被付好幾次:一次是最初的連線握手,一次是隧道自己的加密握手,還有一次是隧道建立好之後真正的請求/回應。換成美國西岸的 VPS——地理上離這條連線的另一端近得多——實際的網頁載入延遲直接砍半。如果一套代理或 VPN「感覺很慢」,值得先老老實實量一次到伺服器的真實往返時間,再去假設是哪裡設定錯了;有時候真正的解法是換一個更近的機房,而不是調整任何軟體參數。

附加:用 Cake SQM 處理 bufferbloat

以上都在處理「流量有沒有被正確導向」,跟另一個常見的家用網路痛點是兩回事:bufferbloat——上傳或下載被跑滿時,其他連線(視訊通話、遊戲、甚至只是開一個新網頁)延遲暴增,即使頻寬本身還夠用,問題出在路由器自己的傳送佇列塞了太多封包排隊。這跟 TPROXY 設定完全獨立,但既然已經在動這台路由器的網路堆疊,值得一併處理,這裡先說清楚原理和該注意的地方,實際數字要自己那條線路量出來才準。

ASUSWRT-Merlin 386.2 之後、支援的機型上,原生內建了 Cake SQM,跟舊版以 DPI 為基礎的 Adaptive QoS、更舊的 Traditional QoS 是三套不同的東西。開啟路徑一般在 WAN 設定裡的頻寬限制器分頁:只需要填入上傳/下載頻寬上限,韌體就會在 WAN 介面掛上 cake qdisc,自動做 flow isolation,不必自己寫任何分類規則。頻寬上限建議填 speedtest 實測值的 85%–95%,留一點餘裕——Cake 要等佇列真正開始堆積才能介入,如果上限設得比線路實際能跑到的速度還高,bufferbloat 還是會在 Cake 來得及反應之前先發生。

值得確認的一點:Cake SQM 作用在介面層的 queueing discipline,不靠封包標記分類;前面 TPROXY 規則裡的 --tproxy-mark 0x1/0xffffffff 是給 policy routing 用的 fwmark,兩者完全不同層次,不會互相干擾。真正需要留意共存問題的,是舊版以 CONNMARK 為基礎的 Adaptive QoS/Traditional QoS——那一套會自己佔用特定的 mark bit 做 HTB 分類,跟其他也用 fwmark 的機制同時開啟時,理論上有 bit 衝突的可能。想解決 bufferbloat,直接開 Cake SQM,不要跟舊版 QoS 引擎同時啟用,是最不容易踩雷的組合。

如果要跟從零開始的人說幾句話

  • 就算感覺像是額外負擔,一個幾秒鐘就能重置的測試環境還是值得建。 上面提到的每一個真實韌體意外,都是建立在 Docker 已經先證出正確的基礎之上——帳號、路由邏輯、DNS 分流。這代表真正硬體上出的每一個問題,都能確定是硬體/韌體的問題,而不是「我是不是把隧道設定錯了」,這讓除錯時間大幅縮短。
  • 不要因為核心自己回報某個架構、或某個核心模組載入成功,就相信某個功能真的能用。 這兩件事都在我真正想用它們支援的功能時騙了我。
  • 「明顯沒人用」的小數字(port、table ID、mark bit)常常不是,在任何一個對自己保留了哪些資源有意見的平台上都一樣。去查保留清單,不要用猜的。
  • 在動手做有風險的變更之前,先把「復原機制」做出來,任何碰到你沒辦法輕易 console 進去的共用基礎設施都適用這條原則。而且要真的拿(人為製造的)真實損壞去演練一次——一個只讀過、從沒真正測試過的還原流程,只是一個希望,不是一個計畫。
  • 「重開機後能自動恢復」跟「裝了開機腳本」是兩回事。 任何需要人工確認才算完成的安全機制(我們的看門狗),套用到無人值守的開機情境時都需要重新設計,而不是照搬同一套邏輯。而且開機腳本執行的時間點,往往比想像中早得多——早到你依賴的儲存裝置、網路連線,可能都還沒準備好。
  • 同一個症狀出現在多台裝置上時,先確認是不是剛好都跟你手邊那台「環境比較複雜」的測試機一樣有問題,還是真的每台都有問題。 我自己的測試機因為同時掛著好幾層網路(Tailscale、多個 Docker bridge),製造了好幾次原本不存在的假警報,直到拿一台環境乾淨的裝置交叉驗證才確定問題到底是不是真的。
  • IPv6 已經不是「可以晚點再想」的東西了,就算你的專案「只是」個 IPv4 專案也一樣。只要你的網路會發真正的 IPv6 位址出去——而現在越來越多網路預設就會——任何完全只圍繞 IPv4 打造的攔截或過濾邏輯,都會有一個完全靜默的死角,而現代作業系統會自己找到這個死角,完全不需要任何人刻意繞過。

上面這些沒有一個是什麼冷門難題。每一個都是只要查五分鐘就能查到的東西——前提是你要先知道該去查哪五分鐘。這大概就是這種專案真正教會你的事:不是那些文件寫得很清楚的協定本身,而是某個平台的文件所假設的東西、跟某一台特定硬體真正給你的東西之間,那個不起眼卻真實存在的落差。