USB Passthrough — 第二階段設計
Note
軟體面已全部完成;八個未決問題全部拍板(見「設計決策」)。 剩餘兩項本質上無法靠寫程式完成:真實 USB 硬體驗證 與 外部人員的安全 sign-off(Phase 2e)。在這兩項完成前, feature flag 維持預設 off。
已完成並發布(rounds 27 / 34 / 37 / 39 / 40 / 41 / 42 / 43):
Phase 1(唯讀列舉)、Phase 1.5(hotplug events)、Phase 2a
(協定 + ABC + LibusbBackend lifecycle + 給測試用的
FakeUsbBackend + feature flag,預設 off)、Phase 2a.1
(完整 LibusbBackend 傳輸 + CREDIT-based 入站流量控制 +
稽核 hook)、viewer 端 ``UsbPassthroughClient``(阻塞式
open / control_transfer / bulk_transfer / interrupt_transfer / close /
list_devices,含 outbound credit 等待與 shutdown 傳播)、Phase 2d
(UsbAcl 持久化白名單、ACL-gated OPEN 含 prompt-callback、
稽核紀錄整合到既有的 tamper-evident 鏈)、Phase 2d.1
(ACL 檔案 HMAC-SHA256 完整性,竄改則 fail-closed)。
Phase 2b — Windows ``WinUSB``: SetupAPI 列舉 + WinUsb_*
傳輸的 ctypes 接線已完成(硬體未驗證)。
Phase 2c — macOS ``IOKit``: 透過 ctypes 的原生 IOKit 列舉已
完成;裝置 claim/傳輸委派給 libusb(macOS 上經硬體驗證的路徑)。
詳見 iokit_backend 模組說明(硬體未驗證)。
backend 選擇: default_passthrough_backend() 依 OS 自動挑
WinUSB / IOKit / libusb。
剩餘流程: Phase 2e — 見 USB Passthrough — Phase 2e 安全審查清單 的審查者清單;feature flag 翻成預設 on 之前必須由外部人員簽核, 且三個 backend 都需在真實硬體上跑過測試矩陣。
目標
讓遠端 AutoControl viewer 使用實體插在 host 機器上的 USB 裝置。 具體使用情境:
在 host 插一支 USB security key;讓 viewer 發起的 WebAuthn challenge 在那支 key 上簽章。
在實驗室 host 插 USB-serial debug board;讓遠端開發者透過自己 本機的終端機跟它對話。
在 host 插一台印表機;讓 viewer 的 OS 把它看成本機印表機。
非目標
高吞吐 isochronous 傳輸(USB webcam、音訊介面)。WebRTC + DataChannel + driver 來回的延遲預算跟 isochronous USB 不相容。 那些情境用既有的 audio/video track。
核心層裝置重導向(如 USB/IP)。我們做的是 userspace forwarder,不是替代 kernel driver。
第二階段在通過明確的安全審查之前不會發布。
傳輸
Channel
每個 session 一條專用的 WebRTC DataChannel,名稱 usb,
ordered=True 且 maxRetransmits=None(完全可靠傳輸)。
已串接: host(webrtc_host)會建立 usb channel 並以
UsbChannelHost 餵給 session(綁定 viewer 認證 + feature flag);
viewer(webrtc_viewer)收到該 channel 後以 UsbChannelClient
包裝,透過 viewer.usb_client() 供上層呼叫 list_devices /
open / resume。adapter 與 transport 解耦,可用 fake channel
單測(見 test_usb_webrtc_channel)。斷線續租: OPENED 會帶
resume_token;若 host session 撐過 viewer 的 transport 中斷,
viewer 重連後送 RESUME{resume_token} 即可重綁原 claim、保留裝置
狀態,不需重新 OPEN。
USB 的 bulk 與 interrupt 傳輸對延遲的容忍度遠高於對遺失的容忍度;
既有的 video/audio channel 也已示範底層 SCTP 傳輸足以承擔有序可靠
串流。
已決(OQ1): 維持 ordered=True + maxRetransmits=None
(完全可靠有序)。USB control 傳輸(如 WebAuthn 簽章、descriptor
讀取)對遺失零容忍,部分遺失的串流會讓裝置狀態機壞掉;可靠有序的
語意比節省幾毫秒重傳延遲重要。maxPacketLifeTime 的有界遺失模型
留給未來若出現純高吞吐、可容忍遺失的使用情境再評估(目前 YAGNI)。
Framing
每個 channel message 是一個前綴長度的協定 frame:
+-----+--------+----------+--------------------+
| 1B | 1B | 2B | payload |
| op | flags | claim_id | (op-specific body) |
+-----+--------+----------+--------------------+
op:1 byte opcode(見下方 操作)flags:8 bits,目前只用到EOF(bit 0,分塊讀取用)claim_id:16-bit 識別碼,代表單一 session 中的一次 device claim。host 在 OPEN 時配發、在 CLOSE 時回收。payload:依 opcode 不同。上限 16 KiB 以維持 DataChannel 訊息 尺寸合理。
已決(OQ2): 已實作分片。protocol.fragment_payload() 把超過
16 KiB 的 payload 切成多個同 claim_id 的 frame,除最後一個外都清掉
FLAG_EOF;接收端串接連續 frame 的 payload 直到看到 EOF。傳輸回覆
與 LIST 回覆都走這條路;single-frame 的常見情形仍只送一個帶 EOF
的 frame,行為與先前一致。
操作
Op (hex) |
方向 |
用途 |
|---|---|---|
|
viewer → host、host → viewer(回應) |
列舉 viewer 有權 claim 的裝置 |
|
viewer → host |
請求 claim (vendor_id, product_id, serial) |
|
host → viewer |
回覆:成功 + claim_id,或錯誤 |
|
viewer ↔ host |
Control 傳輸(bmRequestType, bRequest, wValue, wIndex, data) |
|
viewer ↔ host |
指定 endpoint 的 Bulk IN/OUT 傳輸 |
|
viewer ↔ host |
Interrupt IN/OUT 傳輸 |
|
viewer ↔ host |
Backpressure 視窗更新 |
|
viewer → host |
釋放 claim |
|
host → viewer |
確認(host 端斷線時也可主動發出) |
|
viewer → host |
重連後以 resume_token 重新綁定既有 claim |
|
雙向 |
協定錯誤/不支援 op |
已決(OQ3): LIST 走 channel。session 處理 LIST frame
並回傳 ACL 不會 deny 的裝置(被 deny 的裝置連存在都不讓 viewer 知道);
viewer 端用 UsbPassthroughClient.list_devices() 取得。讓列舉與傳輸
共用同一條已通過 auth gate 的 channel,避免再耦合一層 REST transport,
也讓 ACL 過濾與 claim 決策走同一份邏輯。
Backpressure
雙方各以 16 個未確認 frame 為 claim_id 的初始 credit window。
收一個 frame 消一個 credit;用 CREDIT 訊息傳正整數來補回。
沒有流量控制的話,慢的遠端 USB 裝置會把 DataChannel 送出 buffer
撐爆。
已決(OQ4): 維持 per-claim credit。它已足以達成核心目的—— 防止慢速遠端裝置撐爆 host 送出 buffer——而且狀態最少、推理最簡單。 per-endpoint credit 會把 IN/OUT、多 bulk endpoint 各自記帳,複雜度 明顯上升卻只在單一 claim 內多個 endpoint 同時飽和時才有差別;屬於 YAGNI,待真實量測出現 head-of-line 問題再說。
各 OS driver 包裝
driver 層藏在單一 UsbBackend ABC 後面:
class UsbBackend(abc.ABC):
def open(self, vendor_id, product_id, serial) -> "UsbHandle": ...
def list(self) -> list[UsbDevice]: ...
class UsbHandle(abc.ABC):
def control_transfer(self, ...): ...
def bulk_transfer(self, endpoint, data, timeout_ms): ...
def interrupt_transfer(self, endpoint, data, timeout_ms): ...
def close(self): ...
這把 OS 特定的東西隔離開,讓我們可以在不選定 backend 的前提下 寫協定/session 層。
Windows — WinUSB
對於我們沒有現成 driver 的 HID-class 裝置,最佳路徑:用 libwdi 安裝
WinUSB,或讓使用者透過 Zadig 手動把裝置綁到 WinUSB。用
CreateFile+WinUsb_Initialize+WinUsb_ControlTransfer/WinUsb_ReadPipe/WinUsb_WritePipe。ctypes包winusb.dll的 wrapper 是 public API;不需要 寫 kernel driver。
已決(OQ5): WinUSB 要求裝置 尚未被別的 driver claim,且只有
已綁定 winusb.sys 的裝置會出現在 WinusbBackend.list() 中。因此
host OS 自己擁有的裝置(印表機、hub、鍵盤)根本不會列出來——viewer
看到的就是「可 claim」的子集,不會誤以為能 claim 系統裝置。若 OPEN
的 vid/pid 不在清單中,host 回明確的 no device matches 錯誤;
operator guide 說明如何用 Zadig / libwdi 綁定裝置到 WinUSB。
macOS — IOKit
IOUSBHostInterface(現代版,10.12 起)或IOUSBInterfaceInterface(比較舊但無所不在),透過pyobjc。透過 App Store 發行需要 entitlement 簽章;直接散布的話 OK,但 binary 必須做 notarisation。
IOKit 的
CompletionMethodcallback 整合CFRunLoop,不是 asyncio。需要一個專屬 thread 持有 runloop,把 completion marshal 回 WebRTC bridge thread。
已決(OQ6): 列舉走原生 IOKit(ctypes,不需 pyobjc);claim/
傳輸委派給 libusb——它是 macOS 上經硬體驗證的 USB 路徑,避免手刻
無法在無硬體下驗證的 IOUSBHostInterface plugin vtable。直接散布
(非 App Store)的 build 必須 notarisation;libusb 存取裝置不需特殊
entitlement,但 System Integrity Protection 仍會藏起 Apple 內部裝置與
某些 USB-C 週邊。operator guide 記載 SIP 排除界線。
Linux — libusb
透過
libusb-1.0的pyusb不需要 root,只要udevrule 給使用者存取權。我們會提供範例 rule。拔線處理:libusb 對進行中的傳輸發出
LIBUSB_TRANSFER_NO_DEVICE; 我們把它 map 成 channel 上的CLOSED。
已決(OQ7): 已實作。_LibusbHandle 在 open 時對 active
configuration 的每個介面呼叫 detach_kernel_driver``(只 detach 真的
被 kernel 佔住的),記住動過哪些介面,並在 close 時 ``attach_kernel_driver
復原——否則 session 結束後 host OS 會永久丟掉鍵盤/滑鼠。Windows/
macOS 的 libusb 對 detach 拋 NotImplementedError,會被容忍跳過
(那些平台由 OS 仲裁 driver)。
安全與 ACL
每裝置白名單
存於 ~/.je_auto_control/usb_acl.json:
{
"version": 1,
"rules": [
{"vendor_id": "1050", "product_id": "0407", "label": "YubiKey 5",
"allow": true, "prompt_on_open": true},
...
],
"default": "deny"
}
預設政策是 deny。使用者沒有明確允許過的裝置不能被 claim。
prompt_on_open在每次 viewer 請求 OPEN 時觸發 host 端 modal。 modal 顯示 vendor/product/serial 與請求存取的 viewer ID。Allow rule 可以靠提示中的「記住」勾選持久化。
已決(OQ8): 已實作 HMAC-SHA256。ACL 旁附一個 <acl>.sig
sidecar 簽章;載入時驗證,不符就 fail-closed(default-deny、
integrity_ok 為 False),讓偷偷改寫 JSON 的 process 無法在不同時
偽造簽章的情況下給自己授權。簽章金鑰可插拔——部署可透過建構子的
hmac_key= 傳入由平台 keychain 衍生的金鑰;未指定時會在 ACL 旁
產生一把隨機金鑰檔(POSIX 上 0o600)。注意:同使用者身分的
process 仍可讀金鑰檔而偽造簽章,故高保證部署建議改用 keychain 金鑰
(見 operator guide)。升級前既有的未簽章檔案視為 legacy,仍可載入
(下次儲存即補簽),可用 require_signature=True 拒絕未簽章檔。
稽核
每筆 OPEN、OPENED、CLOSE、ERROR 都附加到既有稽核紀錄,event_type
"usb_passthrough"。Frame 層傳輸紀錄太雜,只在 ERROR 時記錄。
權限
host process 必須以選定 backend 所需的權限執行(Linux udev rule、 macOS entitlement、Windows WinUSB 通常不需要)。README 會逐 OS 寫清楚。
階段
完成 — Phase 1:唯讀列舉(
list_usb_devices)。完成 — Phase 1.5:hotplug events(
UsbHotplugWatcher、/usb/events)。完成 — Phase 2a:協定 +
UsbBackendABC + Linuxlibusbbackend,置於 feature flag 之後。完成 — Phase 2b:Windows
WinUSBbackend(ctypes,硬體未驗證)。完成 — Phase 2c:macOS
IOKitbackend(原生列舉 + libusb 傳輸,硬體未驗證)。完成 — Phase 2d / 2d.1:ACL 持久化 + host 端提示 callback + 稽核整合 + ACL 檔案 HMAC 完整性。
進行中 — Phase 2e:默認開啟之前的外部安全審查 加上 三個 backend 的真實硬體測試矩陣。這兩項本質上需要硬體與外部人員, 無法只靠程式碼完成。
在 Phase 2e 簽核之前,feature flag 維持預設 off。
設計決策(原未決問題)
八個原始未決問題均已拍板,對應實作見上方各節:
OQ1 — Channel 可靠度:``maxRetransmits=None``(完全可靠有序)。
OQ2 — frame 分片:已實作
fragment_payload+ EOF 重組。OQ3 — ``LIST`` 走 channel:是,ACL 過濾後經 channel 回傳。
OQ4 — Backpressure 顆粒度:per-claim(per-endpoint 屬 YAGNI)。
OQ5 — WinUSB 不可 claim 裝置:只列出已綁 WinUSB 的裝置, claim 不到回明確錯誤。
OQ6 — macOS 發行:原生 IOKit 列舉 + libusb 傳輸;notarisation, 無需特殊 entitlement,文件記載 SIP 界線。
OQ7 — Linux kernel driver:open 時 detach、close 時 reattach。
OQ8 — ACL 完整性:HMAC-SHA256 sidecar,金鑰可插拔(keychain)。