Repository navigation
Releases: tokyoxpa3/NetRedirector
Release list
v1.9.1
NetRedirector v1.9.1
語音打洞、DNS 洩漏、IPv6 子網,以及給自家工具的本機控制 API
這一版四件事,前三件都是不會報錯、也不會中斷的問題 —— 出事了只會「安靜地不動作」,
所以都不容易自己發現。
最嚴重的是第一件:UDP relay 會在背景無聲失效,而且失效之後不會自己好,
只能把整個程式關掉再開。它的表現是「Discord 語音偶爾打不通」,但根因跟 Discord 無關,
所有走 UDP 的流量都會掉進同一個洞。
一、修正:UDP relay 的連線會無聲失效,而且永不重建
症狀
用代理時 Discord 語音(WebRTC)打洞失敗,換版本有時好有時壞。同一時間:
- 把 Discord 的規則改成直連 → 打洞正常;
- 繞過引擎、直接對代理做 UDP ASSOCIATE → 也正常;
- 只有「經引擎的 UDP relay」這條路 0/4 全滅,連 DNS over UDP 都 timeout。
把 NetRedirector 整個關掉再開,立刻恢復 4/4。這個「重開就好」是最關鍵的線索。
根因:代理端的 relay 埠死了,但引擎不知道
SOCKS5 的 UDP 是「一條 TCP 控制連線 + 代理端配一個 relay 埠」。那個 relay 埠的壽命
取決於代理後面的 NAT 對應:手機熱點換基地台、或電信商的 UDP NAT 項過期,都會讓它
靜默停止轉發,而 TCP 控制連線靠 keepalive 還掛著 ESTABLISHED。
於是引擎這邊看到的是:
select()永遠等不到 EOF(控制連線還活著);sendto()永遠不會失敗(UDP 無連接,送到死埠不會報錯);- 所以既有的「連續送出失敗就重建」機制永遠不會觸發。
實測到的那一筆:引擎在 17:04 建立的 association,到隔天 09:11 還在被使用,
中間十幾個小時的每一個 datagram 都送進黑洞。
這也是為什麼「1.8.5 之前好像沒事」:那時候 UDP 歸因失敗會直接走直連,
根本沒進 relay。1.8.5 修好歸因之後,流量第一次真的走進這條路,
才撞上這個一直都在的老問題。
修正:用「沉默」當判據,而不是等錯誤
唯一觀察得到的訊號是沒有回程。所以 association 現在會記錄「最後一次收到回程的時間」
與「自那之後送出的 datagram 數」,超過門檻就丟掉它;下一個 datagram 會重新 dial,
跟既有的 sendto 失敗路徑完全一樣。
門檻分兩種,因為「從來沒回過」跟「曾經回過」的意義不同:
| 狀態 | 條件 |
|---|---|
| 曾經收到回程(雙向流量) | 送出 20 個 datagram 且沉默超過 15 秒 |
| 從未收到回程(單向流量) | 送出 200 個 datagram 且沉默超過 60 秒 |
第二列是刻意放寬的:syslog、單向心跳這類流量本來就不會有回程,
用 15 秒的門檻會讓它們每 15 秒被重新 dial 一次,純粹是浪費。
刻意沒有用「閒置超時」當唯一判據 —— Discord RTC 有持續流量,
idle 永遠不會觸發,那正是它能在黑洞裡活 16 小時的原因。
計時用 GetTickCount(),因此也處理了 49.7 天環繞。
二、修正:DNS 查不到來源行程時,不再偷偷走直連
症狀
在「代理 + 一條萬用(*)規則」這種最常見的設定下,大部分 DNS 查詢走代理,
但有一部分的查詢會走直連 —— 而且是明文送出。畫面上、日誌裡都不會有任何異常。
根因:同一個設定,兩種結果
引擎對每一條流量都要先問「這是哪個行程發的」。問不到的時候(pid == 0),
過去的行為是無條件走「未知行程」的處置(預設是直連)。
但 DNS 的處置其實不該取決於「問不問得到行程」:引擎另外有一個設定
「DNS 走代理」(dns_via_proxy,預設開)。於是同一台機器上會同時存在:
- 歸因成功的 DNS → 進規則 → 有萬用規則 → 走代理;
- 歸因失敗的 DNS → 在進規則之前就被攔下 → 走直連。
兩者用的是同一個設定,結果卻相反,而「漏掉的那一半」在日誌上跟正常流量長得一樣。
歸因失敗對 UDP 而言是常態(UDP 沒有 TIME_WAIT、沒有握手,socket 常常在引擎查表
之前就關掉了 —— Windows 的 DNS Client 是慣犯),所以這不是罕見路徑。
修正
pid == 0 且目的埠是 53 時,改成與「查得到 pid 但查不到名稱」同一條路:
帶著空的行程名繼續進規則比對。
- 萬用(
*)、PID、域名規則照樣能匹配; - 指名行程的規則自然不匹配(空名字不可能等於某個行程名);
- 若沒有規則匹配,規則引擎本來就會回直連 —— 最壞情況與過去完全相同。
同時維持對稱:dns_via_proxy 關閉時,不論歸因成功或失敗,DNS 一律直連
(這個偏好優先於任何規則)。這條對稱是刻意保證的,否則就會出現
「一半 DNS 明文外洩」這種看不出來的洩漏。
另外把 pid == 0 時的「防迴圈」判斷(跳過自己行程)移進正確的分支 ——
g_current_process_id 在尚未初始化時是 0,原本會把 pid == 0 誤判成「自己」。
三、新增:IPv6 CIDR 子網規則(例 2001:db8::/32)
症狀
IPv6 的規則只能填完整位址。想擋或想導一個子網,例如「所有
2001:4860::/32 的流量走代理」,規則欄位填 2001:4860::/32 會完全沒有效果,
也不會有任何提示 —— 它被當成一個字串去比對,永遠不會等於任何一個位址。
一個 /64 有 2⁶⁴ 個位址,所以「列出整個子網」不是選項:這個缺口等於
IPv6 的規則功能實際上只有一半。
修正
規則的 hosts 欄位現在支援 IPv6 的 CIDR 表示法:
2001:db8::/32 整個 /32 子網
::/0 全部 IPv6(等同 IPv6 的萬用字元)
2001:db8::/128 單一位址(與直接寫位址等價)
格式錯誤(前綴不是數字、超過 128、位址部分不合法)一律視為不匹配,
不會退化成字串比對 —— 否則打錯字會變成一條永遠不匹配卻毫無提示的規則。
設定介面的 hosts 提示文字也一併加上範例。
順帶修掉的一個無聲缺陷
「這個欄位裡有沒有域名」的判斷過去用的是 is_ip_like_pattern(),而它問的其實是
「是不是點分 IPv4」。於是任何含 : 或 / 的字串(IPv6 位址、以及新的 CIDR)
都被當成域名,每次新增或編輯這類規則就會多觸發一次全機 DNS 解析快取清除
外加兩次註定失敗的解析。既有的 IPv6 精確位址規則本來就會踩到,CIDR 只是讓它變常見。
現在改用專門的判斷(含 : 或 / 就是位址,不可能是主機名)。
⚠️ 已知限制:IPv4 的 CIDR(例如10.0.0.0/8)仍然是陷阱 —— IPv4 分支還沒有
同樣的處理,它會被當成域名解析、必然失敗、規則靜默永不匹配。要動 IPv4 CIDR 時
會連這條一起處理。
四、新增:本機控制 API,以及「自動偵測並加入直連程式」
為什麼需要
同一台機器上如果有我們自己維護的工具(例如 MultiSocksDownloader),它必須
直連才能自己掌握通往各個 SOCKS5 代理的連線;一旦它的連線被 NetRedirector
再轉一次,多代理聚合就整個失效。
過去的解法是使用者手動去規則頁加一條 DIRECT。現在多了一個選擇:
- 自動:齒輪選單的「自動偵測並加入直連程式」。啟動時掃描一次,
清單裡的程式若正在執行、而且使用者還沒為它建過任何規則,就補上一條直連規則。
預設清單只有MultiSocksDownloader.exe。 - 被動:本機 HTTP API,讓工具自己開口要求。
GET /ping 回傳版本與執行狀態
GET /rules[?target=NAME] 查詢規則(可依行程名過濾)
POST /ensure-direct {"target": "x"} 確保該行程名有啟用中的 DIRECT 規則
幾個刻意的設計
- 只綁
127.0.0.1,且要 token。只綁 loopback 擋掉了遠端,但同機的其他行程
還是連得進來,所以請求要帶一個共享 token(放在使用者家目錄,
呼叫端讀同一個檔案即可)。 - 不用具名管道,用 loopback TCP。NetRedirector 必須以管理員身分執行,
呼叫端卻通常是一般權限;具名管道受完整性等級 ACL 限制,管理員行程開的管道
一般權限常被拒絕,loopback TCP 不受此限制。 /ensure-direct是冪等的。C 核心的AddRule不去重,重複呼叫會堆出多條
相同規則,所以已有啟用中的 DIRECT 名稱規則時直接回報,不新增。- 尊重使用者的停用意圖。啟動掃描只看「該行程正在執行」且「使用者尚未為它
建立任何規則(含停用)」,所以你把自動加的規則停用或刪掉之後,它不會每次啟動
又長回來。 - 規則的
auto標記會存進設定檔,這樣使用者刪除時才能一併把它移出清單。
⚠️ 把python.exe這類通用直譯器設成直連,會讓所有 Python 程式一起直連
(包含 NetRedirector 自己如果也是 Python 跑的話)。API 仍允許,但會在日誌留一筆提示。
測試
- C 測試套件:311/311 通過(原本 298)。新增
test_udp_assoc_liveness13 項
(兩種門檻的邊界、雙向/單向分流、GetTickCount環繞);test_utils73→98
(IPv6 CIDR 21 項、hosts 欄位判定 4 項)、test_rules24→33
(歸因失敗的 DNS 在dns_via_proxy開/關下的處置)。 - Python:
pytest246 passed(含新增的test_control_api10 項、
test_auto_direct_integration11 項、test_config_store3 項)。 flake8淨變化 0(以「違規行內容」做多重集合差集,不比行號;新增檔案零違規)。- 測試腳本補上
/utf-8:原始檔是 UTF-8,但cl.exe預設依系統字碼頁(本機為 950)
解讀,字串常值裡的中文會直接編譯失敗。這一步讓測試原始檔可以安全使用中文。
實機端到端驗證
C 測試只證明「比對與決策邏輯」,證明不了「實機真的改道」。這次做了
新舊 DLL 的配對量測(儀器是自製 SOCKS5 代理,記錄引擎實際交出去的東西):
| 項目 | 舊版 | 新版 |
|---|---|---|
IPv6 CIDR 規則 2001:4860::/32 |
Direct (TCP),代理零記錄 |
Proxy (TCP),代理收到 ATYP_IPV6 |
不涵蓋的 CIDR 2001:db9::/32 |
Direct (TCP) |
Direct (TCP)(對照組) |
歸因失敗的 DNS(dns_via_proxy 開) |
未被代理收到(洩漏) | 被代理收到 |
歸因失敗的 DNS(dns_via_proxy 關) |
未被代理收到 | 未被代理收到(偏好優先) |
UDP relay 那一項沒辦法用同一個儀器量,因為要復現的是「代理端的 relay 埠在背後死掉」。
這次的作法是先復現、再確認假設:
| 組 | 路徑 | 結果 |
|---|---|---|
| A | 直連(DIRECT 規則) | 4/4 STUN,來源正確,映射一致 |
| B | 經引擎 relay | 0/4,DNS over UDP 也 timeout |
| C | 繞過引擎直連代理 | 2/2 STUN,映射一致 |
B 全滅而 A、C 都正常 ⇒ 問題在引擎的 relay,不在代理也不在網路。接著查到
「同一條控制連線已存活 16 小時」,把程式重啟(清空 association、拿到新的 relay 埠)
之後探針立刻回到 4/4、映射也換成新埠 ⇒ 老化假設成立。修復後的門檻行為由那 13 項
單元測試釘住。
歸因失敗的 UDP 流量無法「隨手製造」—— 引擎有一層 socket 事件快取會先回答。
這次是用壓力測試逼出來的:灌爆引擎的 socket 事件佇列(它自己就設計成
「寧可丟事件也不要長大」),讓快取答不出來,再送 DNS 查詢。方法與腳本
在.workbuddy-ai/ab/(run_191_ab.py、run_flood_ab.py)。
升級方式
使用內建自動更新即可。若更新流程本身有問題,直接下載本頁 zip 手動覆蓋
(NetRedirector.dll 必須與執行檔同目錄)。
提醒:設定檔
config.json放在程式目錄。手動換版本時請不要把整個資料夾
直接刪掉重解壓,否則設定會跟著消失;內建自動更新會自動保留它。
Full Changelog: v1.9.0...v1.9.1
v1.9.0
NetRedirector v1.9.0
設定不再「重開機就回預設」,VPN Gate 不再被環境斷線打爛
這一版處理兩個不同性質的問題:一個是使用者設定會無聲消失,一個是VPN Gate
的節點穩定度會被跟節點無關的事件污染。另外把 UDP 的歸因訊息降噪,讓 TCP 的
真訊號不再被淹掉。
如果你遇過「設定完之後重新開機,設定全部回到預設」,這一版務必更新。
一、修正:重新開機後設定回到預設
症狀
把自訂代理(IP/Port)、規則、Hub 綁定設定好,重新開機再啟動程式 ——
全部變回預設值,而且畫面上完全沒有任何錯誤訊息。
根因:不是讀不到設定,是從來沒寫進去
設定變更過去只存在記憶體,寫檔的時機只有一個:程式自己跑完關閉流程
(按下「完全關閉」或從系統匣結束)。而新增/編輯代理、規則、Hub 綁定都不會寫檔。
於是重新開機時:
- 程式是被系統結束的,不會走到存檔那條路;
- 就算 Windows 送出關閉通知,程式端的關閉處理也只有兩條路 ——
勾了「關閉時縮到系統匣」會直接縮回系統匣並拒絕關閉,沒勾則跳出
「要完全關閉,還是縮到系統匣?」的對話框卡住關閉流程; - 兩者都不會存檔,最後由 Windows 強制終止行程。
再加上「開機時自動啟動」預設是開的,多數人設定完根本不會主動關閉程式 ——
等於幾乎必然踩到。
修正
- 變更即存:自訂代理(新增/編輯/刪除)、規則(新增/編輯/啟用停用/刪除)、
Hub(新增/刪除/綁定切換/批次全選)、語言、Ping 目標、以及「關閉時縮到系統匣」、
「啟動時自動檢查更新」、「自動管理介面計量」三個勾選項,變更後延遲 800 毫秒
自動寫入config.json。連續操作會合併成一次寫檔,不會一直磨硬碟。 - 關機/登出前強制落地:接上 Qt 的
commitDataRequest(Windows 在
WM_QUERYENDSESSION時發出),在行程被終止前同步寫檔;事件迴圈結束前
再補寫一次尚未落地的變更。 - 載入設定期間不排程存檔:還原勾選框本身會觸發變更訊號,若不擋掉,
剛讀到的設定會被立刻再寫回去(還原中途失敗時更會把「半套」狀態覆蓋成正式設定檔)。
順帶一提,因為關機時不再需要經過關閉對話框,重新開機/關機也不會再被這個程式卡住。
二、修正:VPN Gate 穩定度被「環境事件」污染
症狀
主網路(例如 PPPoE)中斷一次之後,所有被試過的節點穩定度全部爛掉,而且
再也不會自己恢復。
根因
斷線時,每個節點都會被記一次「連線失敗」與一次「被踢除」,connect_attempts
這個分母也被撐大。因為 connect_rate 是終身比率,一次大斷線就會在所有節點上
留下永久疤痕;又因為失敗懲罰上限(0.20 × 5 = 1.00)比加分項上限(0.90)還高,
連續失敗 5 次在數學上必定讓穩定度歸零。
修正
- 新增
net_health.py,用 ICMP 探針判斷主網路是否活著。選 ICMP 不是隨便選的:
引擎的 WinDivert 過濾條件只有tcp與udp子句、沒有icmp子句,
所以 ICMP 完全不進引擎,也不可能被 catch-all 的 PROXY 規則轉走。
TCP/UDP 探針反而會在主網路斷線時經由同一段區網上還活著的手機代理「成功」——
正好在最需要它的時候騙人。 - 有任何一次實際連線成功就一律不算環境事件(主網路真的斷了不可能連得上);
否則才看探針,或「≥2 個節點全數失敗」的結構啟發式。 - 網卡監控端:所有受監控網卡同一輪離線且 ≥2 張 ⇒ 視為環境事件。此時仍記錄
session 時長(真實資料),但不記踢除次數。 - 兩道保險(這個專案反覆被「靜默死路」咬):實際連線成功就立刻推翻探針判定,
之後不再用它阻擋派發;判定 DOWN 期間仍每隔 120 秒硬試一次確認性派發。 - 復原工具:節點面板新增「重置穩定度」按鈕;另有
forgive_failures()
只清連續失敗次數、保留歷史比率。
三、降噪:UDP 的歸因失敗改用獨立節流槽位
同一句「could not be attributed」對 TCP 與 UDP 意義完全不同:
- 對 TCP 它是真訊號 —— 連線追蹤狀態機被問到一個它從沒見過的 flow;
- 對 UDP 它是常態 —— UDP 沒有
TIME_WAIT、沒有握手,socket 常常在查表前
就關掉了(Windows 的 DNS Client 是慣犯),而 datagram 反正原樣送出、與沒開引擎相同。
兩者共用一個節流槽位時,一個忙碌的 DNS 用戶端就能把 TCP 的訊號淹掉 ——
實測引擎日誌裡 72% 的行都是這一類。
現在 UDP 走自己的槽位(60 秒視窗),TCP 的視窗(5 秒)刻意不動,因為那裡縮短的
代價是漏掉真問題。test_logging 補上 4 項回歸守衛,其中一項專門在防「有人把
兩個槽位併回一個」。
測試
- Python:
pytest222 passed(新增tests/test_autosave.py18 項)。
其中一項是端到端回歸:變更 → 延遲 → 設定檔真的出現在磁碟上,
直接鎖住「重新開機後設定回到預設」這個回報。 - C 測試套件:264/264 通過(
test_logging由 11 項增為 15 項)。 flake8淨變化 0(以「違規行內容」做多重集合差集,不比行號)。
升級方式
使用內建自動更新即可。若更新流程本身有問題,直接下載本頁 zip 手動覆蓋
(NetRedirector.dll 必須與執行檔同目錄)。
提醒:設定檔
config.json放在程式目錄。手動換版本時請不要把整個資料夾
直接刪掉重解壓,否則設定會跟著消失;內建自動更新會自動保留它。
Full Changelog: v1.8.5...v1.9.0
v1.8.5
NetRedirector v1.8.5
修正「代理靜默失效」:連上了,卻有一部分的流量沒走代理
這一版只有一個主題:讓代理真的對每一個行程生效,而且失效的時候講得出來。
v1.8.4 之前的引擎有三個各自獨立的缺陷,症狀全都是同一句話 ——
「代理明明開著、規則也對,但某些程式的流量就是直連出去了,而且畫面上毫無線索」。
三者成因完全不同,這一版逐一修掉。
如果你是從 v1.8.4 或更早的版本升上來,而且會在多使用者/遠端桌面的環境下用代理,這一版務必更新。
一、症狀
三種表現,其實是同一個問題的不同切面:
- 遠端桌面(RDP)裡的程式不受代理影響,主控台開的程式卻正常。
- 連線看起來有走代理,但引擎一直在把自己 relay 的封包降級成直連。
- 引擎日誌被同一種訊息灌爆 —— 實測某次 1865 行裡有 1348 行是它。
而且三者完全靜默:沒有任何提示、沒有任何錯誤,只能靠事後翻日誌。
二、三個根因
(1) 引擎沒有 SeDebugPrivilege,讀不到其他帳號行程的名稱
引擎以提權身分執行,但要對 RDP session 裡的行程做
OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION) 時一律回 err=5。
原因不是 session 隔離 —— 是行程自己的 DACL:行程的權限來自建立者權杖,
未提權的桌面只授與「自己」與 SYSTEM,所以提權的引擎反而開不了它。
啟用 SeDebugPrivilege 之後,5/6 立刻可讀。
拿不到行程名稱,引擎就無法比對任何「行程名稱規則」,只能套用
unknown-process action(預設 DIRECT)—— 連 catch-all 的 * → PROXY 都被繞過。
(2) 行程查表的鍵不唯一,會讀到已關閉連線留下的列
get_process_id_from_connection() 只用 (local_addr, local_port) 查表。
但同一個鍵底下合法地有多列:LISTEN 加上它 accept 的連線、以及已關閉連線
留下的 TIME_WAIT 列 —— 而 TIME_WAIT 列的 dwOwningPid 恆為 0。
實測 400+ 個取樣每一個都有重複鍵。最關鍵的一筆:引擎自己 relay 埠
192.168.1.200:33100 的第一列永遠是 pid=0 的 TIME_WAIT ——
也就是說引擎一直在重新分類自己 relay 的封包,並把它們降級成直連。
(3) 連線關閉後被當成新連線重新分類
舊版在第一個 FIN/RST 就把連線追蹤的條目刪掉,而「新連線」分支又沒有
SYN 旗標防護。於是關閉之後的封包(用戶端最後一個 ACK、重送的 FIN、中繼自己的
FIN)全部無條目可匹配,被當成新連線重新分類。
此時 socket 已經沒了,TCP 控制區塊停在 TIME_WAIT、dwOwningPid 為 0 ——
查不到主人,於是套用 unknown-process action。這就是日誌爆量的真正來源。
三、修正
| # | 修正 | 做法 |
|---|---|---|
| 1 | 啟用 SeDebugPrivilege |
NetRedirector_Start() 一啟動就啟用;部分成功要靠 GetLastError() == ERROR_NOT_ALL_ASSIGNED (1300) 判讀,不是回傳值 |
| 2 | 查表鍵改為完整 4-tuple | 第一趟比對完整 4-tuple,第二趟才放寬到「同一 local 端點的活列」,兩趟都必須 dwOwningPid != 0。IPv4 / IPv6 / UDP 一併處理 |
| 3 | 名稱查不到時不再短路 | 改以空名稱繼續走規則比對 —— PID 規則與萬用字元規則仍生效,全都沒中才回 DIRECT(與原本的最壞情況相同) |
| 4 | 只有 SYN 能建立新連線 | 未追蹤的非 SYN 封包原樣重注入,不再被重新分類 |
| 5 | FIN/RST 不再立刻刪條目 | 改為標記「已關閉」,保留 30 秒寬限期(TCP_CLOSING_GRACE_MS),讓關閉期的封包仍走追蹤路徑,之後由 sweep 回收 |
| 6 | 規則判定為 BLOCK 的 flow 會被記住 | 因為守衛放行了未追蹤的非 SYN 封包,不記下來的話「規則改成 BLOCK 之前就建立」的連線會整條存活 |
刻意保留不動:引擎只在新連線的第一個封包(SYN)做一次規則判定。
這表示引擎停止期間就建立的連線,不會被中途接管 —— 中途改寫一條已經直連出去的
連線只會把它弄壞。要讓它生效,重新建立連線即可。
四、這一版新增的「看得見」
過去這些問題之所以難查,是因為引擎只把日誌轉發給介面、完全不落地寫檔,
間歇性問題事後零證據。這一版一併補上:
NetRedirector.engine.log(就在 DLL 旁邊,打包版即IntegratedApp.dist\):
1 MB 輪替成.1、每行fflush、每秒上限 200 行(超出會補一行suppressed N)。
啟動時會 dump 預設路由 + 代理清單 + 規則清單,事後對照不用再猜。- 「無法歸因」的日誌現在同時印來源與目的端點,否則分不出是用戶端流量、
還是引擎自己在 relay 埠上的封包。 - 介面會提示權限問題:拿不到
SeDebugPrivilege時會提示使用者。
只提示、不阻止啟動 —— PID 規則與萬用字元規則仍然有效。 - 高頻失敗路徑(查表、名稱查詢、握手)改為節流輸出,不再洗版。
測試
- C 測試套件 260/260 通過:dns_snoop 29 / filter 32 / lock_stress 1 / logging 11 /
rules 24 / state 68 / udp_rewrite 22 / utils 73。
其中 state 新增 6 項,專門鎖住「FIN/RST 後條目仍存活、關閉後仍可匹配、
錯誤的鍵不會誤標、埠回收時新連線接管」。 - Python:
pytest165 passed。 flake8淨變化 0(含--select=F821的靜態檢查)。- 部署驗證:已部署的
NetRedirector.dll與目前原始碼重新建置的結果,
.text段逐位元相同(唯一差異是輸出檔名嵌在.rdata的 14 個位元組)。
已知行為(不是缺陷)
- UDP 偶爾出現「could not be attributed」是正常的。 UDP 沒有
TIME_WAIT、
沒有握手,端點列會隨 socket 關閉直接消失。實測元凶是 Windows 的 DNS Client
(Dnscache,svchost.exe):它每次查詢開一個新的臨時 socket、送出後很快關掉,
引擎從佇列取出封包時列已經沒了。預設 DIRECT 下該封包原樣送出,與未開引擎相同。 - 對應的降噪(拉長 UDP 的節流視窗)留待後續版本。
升級方式
使用內建自動更新即可。若更新流程本身有問題,直接下載本頁 zip 手動覆蓋
(NetRedirector.dll 必須與執行檔同目錄)。
Full Changelog: v1.8.4...v1.8.5
v1.8.4
NetRedirector v1.8.4
修正 v1.8.3 引入的 UDP 迴歸:遊戲「選完地圖進不去房間」
這一版只有一個主題:把 v1.8.3 那筆「UDP 中繼穩健性」造成的迴歸修掉。
受影響的是所有走代理的 UDP 遊戲與串流 —— 症狀不是變慢,是整條 UDP 路徑在關鍵時刻斷掉。
如果你是從 v1.8.3 升上來、而且玩連線遊戲,這一版務必更新。
一、症狀
Steam《鬥陣特攻》:進入房間、選完地圖之後進不去遊戲,連續三次失敗;
把版本退回 1.8.0 就完全正常。
二、根因
v1.8.3 的 fix(udp): UDP 中繼穩健性 同時加了三件事。每一件單看都有道理,
但其中兩件會把暫時性錯誤升級成持續斷線:
| # | v1.8.3 的改動 | 當初的理由 | 實際後果 |
|---|---|---|---|
| 1 | sendto 失敗就 udp_assoc_drop() |
「relay 死了、但 TCP 控制 socket 還沒偵測到」 | 不看錯誤碼。阻塞式 UDP sendto 在爆量時回的是 WSAENOBUFS (10055) 這類可重試的錯誤 → 丟掉一個封包就拆掉整條 association |
| 2 | 每代理指數退避 2 s → 30 s | 「死代理不該讓每個 datagram 都付一次逾時」 | 一次失敗就開窗;窗口內該代理所有 datagram 被靜默丟棄,而且棘輪到 30 秒 |
| 3 | UDP_ASSOC_TIMEOUT_MS 10 s → 3 s |
「死代理能卡住所有人的時間」 | 重撥更容易失敗 → 更容易把退避棘輪鎖上 |
為什麼第 1 點特別致命:association 是「每代理一條」
UDP_ASSOCIATION 以 proxy_id 為 key,一條吃下該代理全部的 UDP flow。
所以「拆掉一條 association」不是掉一個封包,是該代理所有 UDP 一起斷。
進房間、選地圖那一刻正是 UDP 突發(地圖載入、玩家名單)。一次 sendto 錯誤就足以拆掉
association,接著退避把 UDP 鎖死數十秒 → 遊戲連線逾時、被踢回大廳。連續三次失敗,是因為
退避一旦棘輪上去,短時間內的重試全都落在窗口裡。
v1.8.0 兩者都沒有:sendto 失敗只記一行日誌、association 留著,下一個 datagram 就恢復
—— 毫秒級自癒。這就是「退回 1.8.0 就能玩」的原因。
三、修正
| 項目 | v1.8.3 | v1.8.4 |
|---|---|---|
sendto 失敗 |
立刻拆掉 association | 累計 send_fail_streak,連續 100 次才拆;成功即歸零 |
| 退避開窗條件 | 失敗 1 次 | 連續失敗 2 次(UDP_ASSOC_BACKOFF_MIN_FAILS) |
| 退避窗口 | 2 s 起、上限 30 s | 500 ms 起、上限 5 s |
| ASSOCIATE 逾時 | 3 s | 8 s |
| 退避期間丟包 | 完全靜默 | 進入窗口、以及首次 sendto 失敗都會記日誌 |
真 zombie(relay 真的死了)仍會被及時回收 —— 靠的是既有的「TCP 控制 socket 讀到 EOF」
那條路徑,不需要靠 sendto 失敗去猜。
刻意保留不動的三項 v1.8.3 改動(它們是對的):SIO_UDP_CONNRESET 逐 socket 關閉、
以 FIONREAD 在單輪 select() 內排空、以及只在真的改寫過欄位時才重算 checksum。
四、怎麼確認修好了
實機驗證:更新到 1.8.4 後進入《鬥陣特攻》房間、選地圖、進遊戲,連續多次正常。
若之後又遇到同類症狀,日誌裡的錯誤碼可以直接判別機制:
| 錯誤碼 | 意義 |
|---|---|
10055 (WSAENOBUFS) |
爆量/緩衝耗盡 —— 就是這次修的機制 |
10051 / 10065 |
路徑斷(上游真的掛了) |
10054 |
ICMP 未達(已在 association socket 上關閉通知) |
測試
- C 測試套件 243/243 通過(
NetRedirector/tests/run_tests.ps1)。 - Python:
pytest165 passed。 - 符號配對(版本號沒動,所以用字串證明新碼真的進了 DLL):
新字串retry held for the backoff window/retrying on the next datagram/
keeping association/times in a row, last error=各 0 → 2(IPv4 與 IPv6 兩支),
被刪掉的- backing off2 → 0。
升級方式
使用內建自動更新即可。若更新流程本身有問題,直接下載本頁 zip 手動覆蓋
(NetRedirector.dll 必須與執行檔同目錄)。
Full Changelog: v1.8.3...v1.8.4
v1.8.3
NetRedirector v1.8.3
引擎熱路徑開銷、由代理端解析網域(socks5h)、UDP 中繼穩健性,以及更新器選路的九個缺陷
這一版同時動了引擎(NetRedirector.dll)與更新器(updater.py)。引擎側是三個熱路徑開銷、
一個新功能與一批小修正;更新器側是「在直連與代理之間挑最快」那套機制的九個缺陷 ——
其中好幾個會靜默給出錯誤答案,也就是更新器會長期選到慢的那條路,而且日誌上看不出來。
一、引擎:三個熱路徑開銷
三個各自獨立、都落在封包處理路徑上的成本。以下都是每封包微基準(單機、cl /O2),
不是端到端吞吐。
1. 追蹤中的連線查找走了兩次
原本是 is_connection_tracked*() 先判斷、再用 get_connection*() 取值。但兩者的判斷條件
完全相同(is_udp + src_port + family + memcmp orig_dest_addr),而 get_connection*()
本來就會回報是否命中。於是每一個 outbound 封包都把同一份鏈結串列走兩遍、同一把鎖拿兩次。
改成只呼叫 get_connection*()、用它回傳的命中結果分支:
| 執行緒數 | 修正前 | 修正後 |
|---|---|---|
| 1 | 104.0 ns | 82.2 ns |
| 3 | 233.7 ns | 175.3 ns |
2. 佇列喚醒訊號不再每個封包都發
SetEvent 是核心轉換,實測即使沒有人在等也要約 309 ns;而 worker 每次被喚醒都會把
整個積壓抽乾,所以同一批裡除了第一個以外的喚醒全是多餘的。加上 wake_pending 閘,只在
idle→busy 轉換時發訊號:444.8 → 95.3 ns/封包。
flow_worker()的重置順序是關鍵:必須先重置旗標、再檢查佇列。反過來會吃掉一次喚醒
—— 生產者在空隙中入列,看到wake_pending仍為 1 就不發訊號,該封包得等到 1 秒逾時。
3. 行程 PID 不再重複解析
呼叫端已經跑完整條解析鏈(結果快取 → 事件表 → GetExtendedTcpTable),
check_process_rule() 進來又跑一次。這不是免費的:解析失敗刻意不入快取,所以解析不到的
連線會再付一次完整的 GetExtendedTcpTable(約 650 µs)。改為由呼叫端把已解析的 PID 傳進去
(pid_in / have_pid)。
二、新增:由代理端解析網域(socks5h)
引擎在封包層只看得到目的地位址、看不到主機名,所以「讓代理自己解析」得先把主機名救回來。
做法是從既有的 DNS snoop 反查表(IP → 主機名,由監聽 port 53 的明文 DNS 回應餵養)取回原始
主機名,再以 SOCKS5 的 ATYP_DOMAIN(或 HTTP CONNECT 的 hostname:port)交給代理去解析
—— 換來地理正確的 CDN 選路,且本機不必為此發出 DNS 查詢。
介面上在「代理」頁籤多了一個 「由代理端解析網域 (socks5h)」 勾選框,逐代理設定。
NetRedirector.py 對舊版 DLL 用 hasattr 做能力探測,config_store.py 只在勾選時才寫入
(維持舊設定檔的字典形狀),所以舊設定檔不會被改形狀。
⚠️ 這條路徑有一個會靜默失效的陷阱,一併修掉
萬用子網域規則(例如 *.example.com)只能透過 DNS snoop 反查表命中,而那張表只由
「線路上的明文 DNS 回應」餵養。如果應用程式(或機器上任何行程)在規則存在之前就已經解析過
該主機名,Windows 會直接從 resolver cache 回答,一個 DNS 查詢都不會送出去,snoop 永遠
學不到對應關係 —— 規則於是靜默失效:本該走代理的流量走了直連,而且哪裡都不會報錯。
修正方式:新增 flush_dns_resolver_cache(),在「新增/編輯/啟用帶網域的規則」以及
「Start() 時若已存在啟用的網域規則」時呼叫(Start() 那次刻意排在 DNS refresher 執行緒
之前,讓兩條路徑看到同一個 flush 後的世界)。只帶 IP 或 * 的規則不受影響,所以 flush 綁在
真正需要它的情況上,而不是每次改規則都掃一次。
DnsFlushResolverCache 以 LoadLibraryA 動態解析,DLL 不需要硬連結 dnsapi.lib
(MinGW/GCC 建置路徑維持不變);所有失敗都是靜默 no-op —— flush 只是加速器,不是正確性前提。
它的語意是清掉整個系統 resolver 快取,範圍無法再縮小。
實機量測:開啟/關閉配對
同一台機器、同一條路徑、同一個主機名,只切換「由代理端解析網域 (socks5h)」這一個勾選框,
交錯跑 5 輪(每輪前清一次 Windows resolver 快取,強制 DNS 查詢真的上線):
| 臂 | 代理端實際收到的 CONNECT | 命中 |
|---|---|---|
| 開啟 | ATYP_DOMAIN = http.speed.hinet.net |
5 / 5 |
| 關閉 | ATYP_IPV4 = 210.61.132.1 |
5 / 5 |
開啟時代理自己把該名稱解析成 210.61.132.1 與 2001:b000:1a0:c100::1 —— 也就是「由代理端
解析」這件事真的發生了,不是換個欄位而已。兩臂的 HTTP 回應都是 200。
量測方式:把代理端換成本機一支自製的 SOCKS5 伺服器,直接記錄引擎送出的 CONNECT 位元組。
「引擎交給代理的是主機名還是裸 IP」只存在於那一段連線上,所以代理必須是自己人。
同一輪也把「靜默失效」重現了
同一個引擎 session、同一個主機名,只差在解析前要不要先清 resolver 快取:
| 情境 | 代理端收到的 | 結果 |
|---|---|---|
| resolver 快取已熱、snoop 沒學到 | ATYP_IPV4 = 210.61.132.1 |
HTTP 200 |
| 先清快取 | ATYP_DOMAIN = http.speed.hinet.net |
HTTP 200 |
兩者都是 HTTP 200。這個失效沒有任何徵兆 —— 使用者看到的是一個正常完成的請求,只是它被
本機解析過的 IP 送出去、而不是交給代理解析。這正是 flush_dns_resolver_cache() 存在的理由,
也是為什麼它綁在「帶網域的規則」而不是每次改規則都掃一遍。
三、UDP 中繼穩健性
UDP 中繼跑在單一執行緒上,而 establish_udp_associate_with_config() 是同步 dial 的。
這代表一條死掉的代理會拖垮所有其它 association 的流量。三個修正都繞著這個事實:
-
每代理的 UDP ASSOCIATE 指數退避。沒有它,一條死掉的代理會讓「每一個 datagram」都付滿
一次 ASSOCIATE 逾時,而逾時期間中繼執行緒完全無法服務其它代理。退避窗口掛在共享的
PROXY_CONFIG上(不是傳給establish的那份複本),所以能跨 datagram 存活;成功即重設。
UDP_ASSOC_TIMEOUT_MS也從 10 秒縮到 3 秒 —— 這個逾時就是「一條死代理能卡住所有人的
時間」。修改代理設定(例如修正打錯的 IP)會清掉退避狀態,下一個 datagram 立刻重試。 -
sendto失敗立刻丟掉該 association。relay 已經死了、但 TCP 控制 socket 還沒被偵測到
斷線時,lazy establish 路徑永遠不會恢復 —— 原本的程式碼會一直對著同一個死 association
送、一直失敗。 -
關閉 UDP 的
WSAECONNRESET通知。Windows 會把 UDP send 觸發的 ICMP port-unreachable
回報成 10054,而 relay 的 drain loop 把recv_len <= 0當成「沒有更多資料」就跳出迴圈
—— 於是正常的 per-destination 錯誤(遊戲/串流/P2P 的 peer 三不五時就會 unreachable)
會一直打斷 loop。
實機量測:退避開啟/關閉配對
兩個代理同時掛在引擎上:一個正常(自製 SOCKS5,會回 UDP),一個是死的(TCP 接受連線後
一個位元組都不回應,逼出完整的 3 秒 ASSOCIATE 逾時)。應用端一個 socket 輪流送 —— 先送一個
「好」目的地並等回應,再送一個「死」目的地且不等。量的是好路徑的 RTT:中繼只有一條
執行緒,它被死代理的 dial 卡住時,下一個好封包就得排在後面。
| 6 個死封包 | 退避開啟 | 退避關閉(對照組) |
|---|---|---|
| 實際撥號次數 | 1 / 1 | 6 / 6 |
| 好路徑 RTT 中位數 | 0.15 ms / 0.18 ms | 3.005 s / 3.009 s |
| 好路徑 RTT 超過 1 秒的輪數 | 1 / 6 | 5 / 6 |
| 整個工作負載的牆鐘 | 3.13 s / 3.11 s | 18.12 s / 18.16 s |
| 負載結束後的好路徑 RTT | 0.14 ms / 0.13 ms | 3.004 s / 3.009 s |
(兩臂各跑兩次,以 A-B-B-A 順序交錯,四個數字的差距都在同一個數量級上,不是單次僥倖。)
對照組是把同一份原始碼的 udp_assoc_backoff_allow() 閘門拿掉後重新編譯的,其餘一字未改,
所以兩邊的差別只可能是那個閘門。
「撥號次數」有兩個互相獨立的來源,而且一致:引擎日誌每撥一次就印一行 backing off,
死代理那側的 TCP accept 計數則是同一個數字。只報其中一個都可能誤導,兩個都對上才敢寫。
退避開啟時仍然看得到一次 3 秒停頓 —— 那是第一個死封包該付的代價,也是這個設計的重點:
付一次,然後把窗口拉開,而不是每個封包都付。
四、四件小修正
-
IPv6 濾鏡誤攔 DHCPv6。IPv6 的 UDP 排除子句抄了 IPv4 的 67/68(DHCPv4),但 IPv6 沒有
DHCPv4 —— DHCPv6 用的是 client 546 / server 547。每一次續租都被攔下來走完一趟使用者模式
往返。這從來不是正確性問題(規則引擎會把 multicast / link-local IPv6 強制走 DIRECT),
純粹是浪費。測試同時用「凍結的舊子句字串」對同一個封包做 old-vs-new 對照,證明行為真的
改變,並加了對照封包確認沒有過度排除(過度排除就是 IP 洩漏)。 -
WinDivert 開啟失敗的訊息。原本只印一個數字。五個實際會遇到的錯誤碼全是「環境問題、
不是程式 bug」,現在訊息直接寫出使用者該做的事(防毒隔離了WinDivert64.sys、沒以管理員
身分執行、驅動簽章驗證失敗、殘留的服務登錄項目被停用、被 BYOVD 防護擋下)。 -
佇列補上「位元組」上限。原本只設了封包數上限(
QUEUE_LENGTH),沒有QUEUE_SIZE。
大封包的突發流量可以在封包數還遠低於 16384 時就撞到預設位元組上限,驅動直接丟棄溢出,
TCP 於是看到丟包而把壅塞視窗減半 —— 使用者感受到的是「上傳很慢」。同時把三個
WinDivertSetParam改成不互相串接(原本用||串接,前面一失敗後面就不執行)。 -
行程名解析改用
QueryFullProcessImageNameW。ANSI 版本會用系統代碼頁編碼路徑,非 ASCII
(例如中文)路徑回來的是 GBK/Big5 位元組,永遠比對不到以 UTF-8 存放的規則。
五、更新器:選路探針與分段下載的九個缺陷
「在直連與代理之間挑最快」那套機制有一整批缺陷,多數會靜默給出錯誤答案。完整推導、量測方法
與更正記錄見 docs/updater-path-selection-research.md(本版一併納入 repo)。
| # | 缺陷 | 修正後實測 |
|---|---|---|
| D1 | PySocks 沒裝 → SOCKS5 從來沒進過候選(InvalidSchema 被 except 吞掉) |
已補進 requirements.txt |
| D2 | 探針量的是延遲不是吞吐(分母含 TTFB,976.8 ms 佔 98%) | 119.4 Mbps 被量成 1.1 Mbps → 低估約 100 倍;改為只量首個 chunk 之後 |
| D3 | 1 MB 分段 × 每請求約 1.3 秒延遲 | 段大小下限 8 MB、段數上限 8(curl 對照:完整檔越快,1 MB Range 反而慢 3~7 倍) |
| D4 | DOWNLOAD_THREADS 被完全忽略,worker 數 = 段數(34 MB 開 32 條) |
改為 min(threads, MAX_BLOCKS, ceil(total / MIN_BLOCK_SIZE)) |
| D5 | 失敗不發聲 —— D1 就是靠這個藏了整個產品生命週期 | 逐條輸出原因(逾時/HTTP 碼/例外型別);選路日誌印出全部候選與各自速度 |
| D6 | 標籤說謊:引擎全局轉發時「直連」不是直連(出口 IP 實測相同) | 改為「系統路由(未指定代理)」 |
| D7 | 小於約 3.5 MB 的資產被誤判「樣本不足」→ 所有快路徑一起被丟掉 | 整份抓完算完整樣本;0.25 MB 由「丟棄」變成 7.5 Mbps |
| D8 | 同一條路徑被探測兩次,再付一次 TTFB(1.7 |
34 MB 資產下載段 4.28 → 2.67 秒(省 38%),_probe_download 1 → 0 次 |
| D9 | 伺服器宣告 Accept-Ranges: bytes 卻對任何 Range 回 416 → 完全無法下載 |
0.02 秒就 416 失敗 → 3.74 秒 / 89.8 Mbps(curl 對照 3.77~3.88 秒) |
其中 D1 的後果值得單獨講:requirements.txt 少了 PySocks,而 CI 是
pip install -r requirements-dev.txt(內含 -r requirements.txt)——已發佈的每個版本,
更新器裡的 SOCKS5 通路都是死的,而且因為 D5 把例外吞掉,完全看不出來。
六、新增:自適應連線數(ADAPTIVE_CONCURRENCY,預設關閉)
只有預估下載時間超過門檻(ADAPTIVE_MIN_EST_SECONDS = 10 秒)且檔案夠大時,才開 4 條連線做
一次共同視窗的試探;聚合吞吐要贏基準 1.35 倍才採用,連線延遲漲超過 1.25 倍就退回單連線
(Vegas 式煞車)。門檻經濟學:試探成本 TTFB + 1.2 秒 ≈ 2.5 秒,打平需要
est > 2.5 × 1.35/0.35 ≈ 9.6 秒 —— 快線不試探、慢線才試探。
開啟方式:config.json 頂層加 "adaptive_concurrency": true,或改
updater.ADAPTIVE_CONCURRENCY = True。讀不到就回預設值,更新流程不因設定檔問題中斷。
⚠️ 為什麼預設關閉(已知限制)
- 探針自身的重複性(±40%)比它要偵測的效果(1→4 條連線的真實增益 1.02 倍)大一個量級。
- 真網路端到端試探(把門檻暫時壓低以強制觸發)出現過同一條路徑、相隔幾十秒,結論就從
「採用」翻成「退回」:
NetRedirector(34 MB) 探針 4 條:吞吐 1.54x、延遲 0.34x → 採用
NetRedirector(34 MB) 探針 4 條:吞吐 0.98x、延遲 0.68x → 退回單連線
- 尚未在第二條「會獎勵並行」的路徑上驗證(本機這條 5G 路徑的實測是 1→4 條沒有聚合增益)。
- v2(傳輸中動態增減 worker,真正的 AIMD)刻意延後,等第二條路徑驗證過再說。
測試
- C 測試套件 243/243 通過(
test_dns_snoop 29、test_filter 32、test_lock_stress 1、
test_rules 24、test_state 62、test_udp_rewrite 22、test_utils 73)。
新增的是 DHCPv6 濾鏡的 old-vs-new 對照組與hosts_field_has_domain的判定。 - 引擎側兩項端到端配對量測(socks5h 與 UDP 中繼退避,見第二、三節)。這不是單元測試:
是在實機上「開啟/關閉」交錯執行的量測,對照組由同一份原始碼重新編譯產生。 - Python:
pytest165 passed(新增 D7/D8 7 條、D9 4 條、自適應連線數 11 條)。 flake8沒有新增任何違規。比對方式是把每條違規對應回原始碼行內容做多重集合差集
(不比行號,行號會位移):updater.py40 → 38 條、tests/test_updater.py7 → 6 條,
減少的 3 條是既有的超長行與檔尾空行被順手折掉,新增 0 條。- 姊妹專案
MultiSocksDownloader:python -m unittest tests.test_updater26 passed
(13 → 26);flake8淨變化 0。
本版的量測範圍(說清楚哪個數字是哪種)
- 每封包微基準(第一節的 ns 數字、D2/D3 的探針誤差):單機受控量測,不是端到端吞吐。
- 端到端配對量測:引擎側的 socks5h(第二節)與 UDP 中繼退避(第三節)都是實機、交錯的
「開啟/關閉」配對;更新器的 D8(4.28 → 2.67 秒)、D9(3.74 秒 / 89.8 Mbps)與自適應
連線數的真網路試探同樣在實機、真實網路上量測,並與curl這條獨立協定棧對照。
提醒:引擎修正都在
NetRedirector.dll與執行檔內,更新器修正則在程式自己的更新流程裡。
使用內建自動更新即可取得;若更新程式本身卡住(例如還在 1.8.2 之前、SOCKS5 通路是死的),
請直接下載本頁 zip 手動覆蓋。
Full Changelog: v1.8.2...v1.8.3
v1.8.2
NetRedirector v1.8.2
效能修正:解除約 200 Mbps 的吞吐天花板,並移除每條新連線的固定系統呼叫開銷
這一版是針對核心轉發引擎(NetRedirector.dll)的效能修正。起因是本機實測發現:
經過 NetRedirector 的單一連線只能跑約 190 Mbps,而同一條網路路徑直通時可以跑 260–300 Mbps。
完整診斷過程與實測數據原記錄於 docs/perf-diagnosis-2026-09-27.md;該報告已自 repo 移除,
如需查閱可從 git 歷史取得。
1. 解除吞吐天花板:不再把接收緩衝釘死在 64 KB
通道 socket 原本呼叫 setsockopt(SO_RCVBUF, 64KB)。在 Windows 上,只要呼叫了 setsockopt(SO_RCVBUF),
該 socket 的接收視窗自動調整就會失效,視窗從此被綁在 64 KB 附近。以實測的通道 RTT(約 2.5 ms)計算,
64 KB ÷ 2.5 ms ≈ 210 Mbps —— 這正是觀察到的天花板。
三臂交錯受控實驗(同一目的地、同一檔案,只差這行 setsockopt):
| socket 設定 | 平均 | 最差 |
|---|---|---|
setsockopt(SO_RCVBUF, 64KB) |
230.9 Mbps | 171.6 Mbps |
| 不呼叫 setsockopt(自動調整) | 281.0 Mbps | 263.7 Mbps |
setsockopt(SO_RCVBUF, 256KB) |
279.2 Mbps | 261.5 Mbps |
修正方式:移除該行,讓 Windows 的自動調整生效(不是把數值加大而已 —— 重點在於「有沒有呼叫它」,
Windows 的預設值本來就是 65536,但不呼叫時自動調整仍然有效)。
2. 中繼自己的通道流量不再進入使用者模式
WinDivert 濾鏡原本是 tcp and outbound,會把中繼自己連往代理伺服器的通道封包也一起攔下。
這些封包最終一定判定為 DIRECT、不修改任何欄位,卻仍走完「recv → parse → 雜湊 → 配置記憶體 →
排隊 → 喚醒 worker → 再 parse → 重算 checksum → 送回」的整趟使用者模式流程 —— 也就是
100% 的代理流量都為一個永遠是「不要動它」的決定付了一次完整來回。
修正方式:新增 build_windivert_filter(),對每個已啟用的代理端點加上排除條件。
排除條件同時比對位址與埠(address AND port)—— 只排除位址的話,連到同一台主機其他埠的連線
也會被漏成 DIRECT,那就是真實 IP 外洩。主機名形式的代理位址會被安全跳過(濾鏡語言沒有 DNS),
只記 warning、不影響啟動。
3. 沒改寫的封包不再重算 checksum
原本所有被攔下的封包都無條件重算 checksum。實際上「判定為 DIRECT」與「來自代理端點的入站封包」
這兩類完全沒有改寫任何欄位,其 checksum 本來就是有效的。修正後只在真的改寫過時才重算。
4. 封包佇列:不再每包 malloc,worker 一次喚醒排空整批
原本每一個封包都要 malloc 一份拷貝、並用一個 counting semaphore 交棒给 worker。
在 1 個接收執行緒 + 3 個 worker 的情況下,等於全部執行緒都在爭用處理程序堆積(heap 最不擅長的
正是這種固定大小、極短生命週期的物件)。
修正方式:環狀佇列的每個槽位預先配置緩衝區(2048 B,必要時才成長)並重複使用;
semaphore 換成 auto-reset event,worker 改成一次喚醒就排空整個 backlog。
5. UDP relay 每輪排空整批 datagram
原本每個 socket 每輪 select() 只收一個 datagram,所以一陣突發流量要付出一次 select()
系統呼叫 per datagram。修正後以 FIONREAD 探測是否還有待收資料,在單輪內排空(上限 64 個)。
6. 防火牆規則不再無限累加
netsh advfirewall firewall add rule 不會取代同名規則,而是再新增一條。
因此任何沒有配對到成功 Stop 的 Start(程式崩潰、被強制結束、Stop 失敗)都會留下規則,
每次啟動再加一條 —— 本機實測已累積 118 條(59 TCP + 59 UDP)。
修正方式:新增規則前先刪除同名規則(靜默,因為規則不存在時 netsh 本來就回非零),
而 delete rule name=... 會刪掉所有同名規則,所以順帶清掉既有的積累。
7. 新連線的行程查詢改走 socket 事件快取
每條新連線都要查詢「這個連線屬於哪個行程」,原本的做法是快照整份系統連線表
(GetExtendedTcpTable),實測約 650 µs/次,而且是固定成本(468 列與 173 列的 IPv6 表都是 ~640 µs,
去掉 owner-PID 欄位也要 621 µs)。這是整個專案單價最高的操作:開 100 條連線的網頁 =
65–130 ms 的純系統呼叫開銷,而且它跑在 flow worker 上、會讓同一條 flow 的封包排隊。
修正方式:新增 NR_PidMap.c,開一個 WinDivert SOCKET 層 handle,用 socket 生命週期事件維護
(協定, 本地埠) → pid 的快取表。先查快取,miss 才回退原本的系統表 —— 命中時 650 µs → 約 0.1 µs,
競態時行為與修正前完全相同。安全設計:
- fail closed:同一個
(協定, 埠)出現兩個不同 pid 時該槽停止作答(回 miss,走權威表),
所以快取只可能是捷徑,不可能給出錯誤答案。 - 可選加速器:handle 開不起來時只記 log,所有查詢走原本的系統表,絕不讓
Start()失敗。 - 停止時會在 log 輸出命中率統計(例如
PID map: 38/40 process lookups served from socket events (95%))。
補充:SOCKET 層必須帶
WINDIVERT_FLAG_SNIFF。官方文件寫只要RECV_ONLY,照做會讓全機的
bind()回WSAEACCES (10013)(實測 0/12 可建立)。本版已硬性固定為SNIFF | RECV_ONLY。
測試
- C 測試套件 222/222 通過(
test_dns_snoop 29、test_filter 21、test_lock_stress 1、
test_rules 24、test_state 62、test_udp_rewrite 22、test_utils 63)。 - 新增
tests/test_filter.c(21 項):以WinDivertHelperCompileFilter/EvalFilter
(純使用者模式、不需驅動與管理員)同時建構舊與新兩份濾鏡逐案例比對,
斷言「行為差異恰好等於預期的 4 個通道案例,其餘 bit-for-bit 不變」。
含「同一台代理主機的其他埠仍會被攔截」的 IP 外洩防護回歸測試。 - 新增基準/探針程式:
bench_conn_lookup.c、bench_packet_helpers.c、bench_pid_cache.c、
probe_flow_layer.c、probe_pid_map.c、probe_socket_layer_safety.c。
pid 快取的正確性閘門:與權威系統表比對 TCP 24/24、UDP 16/16 一致,CONFLICT 0。
已實測:吞吐天花板解除
發行後在實機上補做了端到端複測。受測的就是 v1.8.2 的引擎本體
(IntegratedApp.dist\NetRedirector.dll,md5 E0E4EE1E…)—— 經位元組比對確認執行中的
就是修好的版本,不必關閉程式、不必重啟即可量測。
吞吐:A 中繼 vs B 直通(交錯 6 輪 × 6 秒 + 暖機)
同一目的地(http://http.speed.hinet.net/test_1024m.zip)、同一時間窗,兩臂交錯:
| 臂 | 路徑 | median | min | max | avg |
|---|---|---|---|---|---|
| A | 經 NetRedirector 中繼(本機 33100) | 254.6 Mbps | 226.5 | 272.1 | 252.8 |
| B | 直通 SOCKS5(curl -x socks5h://192.168.1.178:1080) |
261.0 Mbps | — | — | 249.4 |
(B 臂該輪只留下 median / avg,min / max 未記錄。)
A/B = 0.976 —— 中繼相對直通只差 2.4%,等於「經過引擎」在吞吐上幾乎不再有代價。
對照修正前(原診斷報告 §5):同一條路徑的 A 臂 5 輪有 4 輪落在
182.7–197.5 Mbps,且精準貼在「64 KB ÷ 2.5 ms ≈ 210 Mbps」的預測天花板上。
本輪 max 已到 272.1 Mbps,越過舊天花板 —— 天花板確實解除了。
新連線延遲:引擎自身只佔約 4 ms
15 對「全新連線的小請求」交錯量測(中位數,單位 ms;curl 的計時欄位是累計值,下表已相減):
| 階段 | A 中繼 | B 直通 | 差 |
|---|---|---|---|
| DNS | 9.46 | 0.04 | +9.42 |
| TCP connect | 11.58 | 5.57 | −3.40 |
| 請求/回應 | 76.57 | 66.69 | +3.87 |
| 合計 | 76.70 | 66.75 | +9.95 |
- DNS 的 +9.42 ms 是設計行為:
g_dns_via_proxy = TRUE,DNS 刻意走代理解析,不是回歸。 - TCP connect 反而快 3.4 ms:中繼在本機就把交握完成,客戶端不必等跨網往返。
- 扣掉上面兩項,引擎自身對每條新連線只增加約 4 ms。
因此第 7 節的 pid 快取,正確的理解是「移除每條新連線 650 µs 的固定系統呼叫」,
而不是「大幅降低連線延遲」—— 0.65 ms 只是那 4 ms 裡的一小部分。
pid 快取的實機命中率
引擎停止時會輸出統計。本次實機連續運行約 1 小時 42 分(真實流量,非合成)後停止,log 為:
[DLL] PID map: 15541/23554 process lookups served from socket events (66.0%), 2473 ambiguous, 235199 events consumed
- 命中率 66.0%(15541 / 23554):三分之二的行程查詢由 socket 事件快取直接回答,
不必再快照整份系統連線表。 2473 ambiguous是 fail-closed 設計生效的次數(同一(協定, 埠)出現兩個不同 pid 時
該槽停止作答、改走權威表)—— 快取只可能是捷徑,不可能給出錯誤答案。- 微基準:命中時 650 µs → 0.16 µs;正確性閘門與權威系統表比對
TCP 24/24、UDP 16/16 一致、CONFLICT 0。
其他現場證據
- 防火牆規則去重:修正前本機已累積 118 條(59 TCP + 59 UDP);本版啟動後實測
恰好 2 條,log 也明確顯示「先 delete 同名規則、再 add」。 - 濾鏡排除代理端點:log 顯示
excluding 8 proxy endpoint(s) from capture,
中繼自己的通道封包不再進入使用者模式。 - 快取層級與 TTL 也一併輸出:
PID map: socket-event cache up (layer=SOCKET, ttl=3000 ms)。
提醒:這些修正都在 NetRedirector.dll(核心引擎)與執行檔內。使用內建自動更新即可取得;
若更新程式本身卡住,請直接下載本頁 zip 手動覆蓋。
v1.8.1
NetRedirector v1.8.1
更新下載修正:不再卡在 96%、速度 0 B/s
這一版修正自動更新下載可能「卡住不動」的問題。若你曾在「檢查更新」時看到進度條停在某個百分比(例如 96%)、速度顯示 0 B/s、連線數不再變動,放著很久都沒反應,本版即為此修正。
問題原因
多線程分段下載的停滯偵測寫在「收到資料之後」的迴圈裡。當某條連線完全不再送資料時,下載執行緒會卡在阻塞的 socket 讀取,永遠走不到停滯判斷那一行,因此「連續數十秒沒有位元組」根本不會被偵測到。唯一保底是 socket 讀取逾時,原本設 60 秒,再乘上每段重試與多個分段,看起來就像永久卡住。
補充:Windows 上無法從另一條執行緒關閉 socket 來取消阻塞中的讀取,因此不能靠「外部關閉連線」解鎖,必須從逾時與停滯偵測著手。
修正內容
- 縮短讀取逾時:單次讀取逾時由 60 秒降為 20 秒,讓任何阻塞讀取都會自行逾時拋錯,帶動既有重試邏輯接手。
- 新增停滯 watchdog:監看整體位元組數是否前進,連續 45 秒毫無成長即中止本次下載並改走下一條路徑,而不是無限重試同一批停滯區段。
- 分段等待加上上限:停止後仍存活的下載執行緒最多再等一段固定時間就放棄,確保不會無限等待。
- 重試前重新探測:重新取得簽名下載 URL,避免沿用過期的 GitHub 簽名 URL 而取得 403。
測試
新增回歸測試 test_download_file_aborts_on_stalled_connection,以「送出少量資料後即卡死」的伺服器驗證下載會迅速中止而非無限卡住。
提醒:這項修正隨 1.8.1 一起發佈。仍停留在舊版的使用者,其內建的舊版更新程式不受此修正影響;若更新時遇到卡住,請直接下載本頁的 zip 手動更新一次。
Full Changelog: v1.8.0...v1.8.1
v1.8.0
NetRedirector v1.8.0
UDP 代理修正:回程封包來源位址還原
這一版修正了 UDP 代理回程封包的來源改寫。若你曾遇過「TCP 走代理正常,但遊戲的 UDP 連線(配對、語音、QUIC)明明有送出封包卻一直連不上、或連上後馬上斷」,本版即為此修正。
問題原因
代理伺服器把遠端伺服器的回覆送回本機時,封包來源是本機的 relay socket。舊版只把來源「端口」還原成遠端伺服器的端口,來源「位址」卻留成本機位址,於是這個回覆看起來像是應用程式自己送給自己的。
遊戲客戶端使用的是 connect() 過的 UDP socket,這種 socket 會直接丟棄任何來源不是其對端的封包 —— 因此回覆全被丟掉,配對連線永遠建立不起來。位址與端口必須取自同一筆連線記錄才算真正還原。
修正內容
- 回程來源完整還原:改寫來源時同時還原遠端伺服器的位址與端口(取自同一筆 flow 記錄),讓
connect()的 socket 能正確接受回覆。 - 負載平衡 / Anycast 後端放行:部分遊戲後端位於負載平衡器後方,會由不同位址回覆。舊版以「位址必須完全相符」比對,這類回覆一律被當成「無對應連線」丟棄。本版改為先找完全相符者,找不到時以端口比對放行,並在主視窗日誌留下痕跡:
UDP relay: response from unexpected address 1.2.3.4:26500 - matched by port - 加大 UDP socket 緩衝:relay 與每條 UDP 關聯的 socket 收送緩衝由系統預設提高至 1 MB。這些 socket 同時承載所有 UDP 流量,遊戲載入地圖、QUIC 起速時的突發量會把預設緩衝塞爆並丟棄資料包,玩家端即表現為封包遺失。
測試
新增主機端單元測試 test_udp_rewrite,把上述決策抽成純函式並鎖住行為(22 項斷言全數通過),涵蓋:
- 已知遠端 → 來源改寫為伺服器位址 + 端口;未知遠端 → 來源保持不變
- IPv4 只寫 4 位元組、IPv6 寫滿 16 位元組
- 完全相符優先,同端口不同位址走端口後備比對
- family 不符 / 未知端口 / 清空後 → 皆為不命中
Full Changelog: v1.7.3...v1.8.0
v1.7.3
NetRedirector v1.7.3
更新下載大幅加速:智能分流 + 防呆
這一版重做了自動更新的下載路徑選擇。若你曾遇過「瀏覽器下載很順,但程式內建更新走走停停、速度一直歸零、要拖很久才完成」,本版即為此修正。
智能分流
下載更新前,程式會並行實測「直連」與「設定中的每一條代理」的實際吞吐(各抓 256 KB 實際資料計速,而非只量 ping 延遲),再挑最快的一條來下載。選路結果會寫入主視窗日誌,例如:
更新下載路徑: MyPhone (0.22 MB/s)
代理若在傳輸途中失敗,會自動改用次快的路徑,最後保底直連:
路徑「MyPhone」失敗,改用下一條: ...
更新下載路徑: 直連 (0.13 MB/s)
防呆(不會因為代理壞掉而更新失敗)
| 情境 | 行為 |
|---|---|
| 代理連不上 / 不支援 | 量測階段即略過,自動走其他路徑 |
| 代理中途斷線 | 自動改用下一條,最後保底直連 |
| 沒有設定任何代理 | 完全不量測,零額外延遲,行為與舊版相同 |
config.json 不存在 / 損毀 / 非 JSON |
視為無代理,不拋出例外、不中斷更新 |
SOCKS5 一律以 socks5h:// 連線(由代理端解析 DNS),避免本機 DNS 被轉址規則影響;代理密碼沿用既有的 DPAPI 加密存放機制,解密失敗時視為無密碼。
下載引擎改進
- 提高並行分段:分段大小 4 MB → 1 MB、分段數上限 16 → 32,連線數隨檔案大小提升。
- 速度顯示平穩化:GitHub 的 CDN 常以突發方式送資料,舊版用 0.5 秒瞬時速度估算,幾乎每個停頓窗都顯示
0 B/s(實測一個 32.7 MB 的下載出現 183/243 次歸零)。本版改用數秒滑動視窗平均,忠實反映實際吞吐。
實測同一條不穩定的線路,32.7 MB 的更新由 123 秒縮短至約 32 秒,速度歸零的顯示次數由 75% 降至 13%。
本加速由 v1.7.3 起的更新流程生效;也就是說,之後的版本更新才會享受到這套分流。
Full Changelog: v1.7.2...v1.7.3
v1.7.2
NetRedirector v1.7.2
重要:舊版本(v1.7.1 及更早)可能「更新成功卻仍停在舊版本」
若你目前使用的是 v1.7.1 或更早版本,在連續更新時可能遇到這種情況:更新流程顯示成功、程式也重新啟動了,但版本號沒有變,仍是舊版。
原因:更新程式在替換檔案前沒有先卸載 WinDivert 核心驅動。驅動載入後會鎖住 WinDivert64.sys,使舊版目錄 IntegratedApp.dist.old 無法刪除;下一次更新時目錄交換因該殘留目錄已存在而失敗,而舊版程式又把「舊 exe 仍在」誤判為更新成功,於是重新啟動的其實是舊版本。
解法(任一即可):
-
重新開機後再更新 — 重開機會卸載驅動並釋放檔案鎖定,之後即可正常更新。
-
手動清除殘留目錄 — 先關閉 NetRedirector,再以「系統管理員」身分執行:
sc stop WinDivert Remove-Item "<安裝目錄>\IntegratedApp.dist.old" -Recurse -Force
完成後重新啟動程式並再次執行更新。
升級到 v1.7.2 之後,上述殘留會由更新程式自動處理,不需要再手動介入。
本版修正
- 修正連續更新卡在舊版本:更新前先停止 WinDivert 驅動,釋放被鎖定的
.sys檔案。 - 殘留舊目錄自動讓位:
.old無法刪除時改名為.old.stale.<時間戳>,不再阻擋目錄交換。 - 交換成功改為實際驗證:必須確認
.new已被消耗且.dist就位,不再只檢查 exe 是否存在。 - 鎖定檔案排程刪除:仍無法刪除的殘留會註冊於下次開機自動移除。
- 更新日誌更明確:逐項記錄清理結果,失敗可立即定位。
- 更新失敗時仍會重新啟動程式:避免更新失敗後程式停在關閉狀態。
Full Changelog: v1.7.1...v1.7.2