USB Passthrough — 操作員指南

實際把 host 機器上的 USB 裝置借給遠端 viewer 用的步驟手冊。host 端在 Linux libusb 上端到端運作;Windows WinUSB 與 macOS IOKit 已實作但 硬體未驗證——兩者都必須先通過 Phase 2e 硬體測試矩陣才能用於 production。default_passthrough_backend() 會依當前 OS 自動挑 backend。

如果你是安全審查者而非操作員,請看 USB Passthrough — Phase 2e 安全審查清單。如果你想要協定細節, 請看 USB Passthrough — 第二階段設計

前置需求

host(有實體 USB 裝置的機器)上:

  • Python 3.10+ 並安裝 AutoControl。

  • 選用的 webrtc 套件:pip install je_auto_control[webrtc]

  • 如要使用 libusb backend 需安裝 pyusbpip install pyusb

  • 預計給 viewer 用的 USB 裝置已插上。

  • 各 OS 設定(見下方 driver 設定)。

viewer(將使用該裝置的遠端機器)上:

  • Python 3.10+ 並安裝 AutoControl。

  • 能連到 host 的 REST API port(預設 9939), 在 NAT 後方時 能連到 WebRTC signalling / TURN 端點。

  • host 的 bearer token(操作員以帶外管道交付)。

Driver 設定(依 OS)

Linux(libusb)

libusb backend 是目前最完整測試過的路徑。步驟:

  1. 安裝 libusb-1.0 開發檔(例如 apt install libusb-1.0-0)。

  2. 加上 udev rule,讓 AutoControl host 程序不需要 root 就能 claim 裝置。例:YubiKey 5(vendor 1050、product 0407):

    # /etc/udev/rules.d/99-autocontrol-usb.rules
    SUBSYSTEM=="usb", ATTRS{idVendor}=="1050",
      ATTRS{idProduct}=="0407", MODE="0660",
      GROUP="plugdev"
    

    接著 sudo udevadm control --reload && sudo udevadm trigger

  3. 確認 AutoControl 使用者在 plugdev 群組。

  4. 若裝置是 HID,AutoControl 的 libusb wrapper 會在 open 時 detach usbhidclose 時 re-attach。所以在 claim HID 裝置時 你的本機鍵盤輸入可能會短暫停頓,這是正常。

Windows(WinUSB)— 硬體未驗證

ctypes 接線已寫但尚未對實體硬體驗證。視為 alpha。步驟:

  1. Zadig 或 libwdi 把目標裝置綁到 WinUSB driver。不要 對 host OS 已經管理的裝置做這件事 (印表機、hub、鍵盤)。

  2. 綁好後裝置應該會出現在 WinusbBackend().list() 中。

  3. 在依賴 transfer 之前需要硬體測試。期待的測試矩陣見安全審查清單。

macOS(IOKit)— 硬體未驗證

IokitBackend 透過原生 IOKit(ctypes,不需 pyobjc)列舉 USB 裝置,所以 IokitBackend().list() 可用。claim 裝置做 transfer 則 委派給 libusb,請安裝:pip install pyusbbrew install libusb。 注意:

  1. 直接散布(非 App Store)的 build 必須 notarisation;libusb 存取 裝置不需特殊 entitlement。

  2. System Integrity Protection 會藏起 Apple 內部裝置與某些 USB-C 週邊——它們不會出現在 list() 也無法 claim,屬正常。

  3. transfer 為硬體未驗證;依賴前請看安全審查清單的測試矩陣。

啟用 feature

USB passthrough 預設 off。兩種開啟方式:

  • 環境變數,於程序啟動時讀取:

    export JE_AUTOCONTROL_USB_PASSTHROUGH=1
    python -m je_auto_control.cli start-rest
    
  • 程式控(覆蓋環境變數),於你的 bootstrap 腳本中:

    from je_auto_control.utils.usb.passthrough import enable_usb_passthrough
    enable_usb_passthrough(True)
    

確認用 is_usb_passthrough_enabled():

from je_auto_control.utils.usb.passthrough import is_usb_passthrough_enabled
assert is_usb_passthrough_enabled()

ACL 設定

ACL 預設為 "deny",所以 viewer 無法 claim 操作員未核准的裝置。 新增 per-device rule:

  1. 從 GUI — host 的 USB 分頁在第一次 OPEN 未知裝置時會跳出 prompt 對話框。勾 記住這個決定 把永久 allow rule 寫入。

  2. 從 Python:

    from je_auto_control.utils.usb.passthrough import (
        AclRule, UsbAcl,
    )
    acl = UsbAcl()
    acl.add_rule(AclRule(
        vendor_id="1050", product_id="0407",
        serial=None,            # match 任何 serial
        label="YubiKey 5",
        allow=True,
        prompt_on_open=False,   # 一旦核准就靜默 allow
    ))
    
  3. 直接編輯 ~/.je_auto_control/usb_acl.json。檔案有權限檢查 (POSIX 上 mode 0600)。壞 JSON 或未知 version 會退到 預設 deny。若你手動編輯檔案,HMAC 簽章會對不上而導致 ACL fail-closed(見下)——請改用 UsbAcl 重新儲存以刷新簽章。

決策優先序:

  • 第一個 match 的 rule 勝。prompt_on_open=True 表示每次都重問 操作員,即使 rule 是 allow=True

  • 沒有 rule match 時套用檔案的 default(預設 "deny")。

ACL 檔案完整性(HMAC)

ACL 旁附一個 usb_acl.json.sig sidecar HMAC-SHA256 簽章。載入時對 檔案位元組驗證;不符就 fail-closed(default-deny, UsbAcl.integrity_okFalse)。這擋住偷偷改寫 JSON 想給自己 授權的 process。

  • 預設簽章金鑰是隨機 32-byte 檔 usb_acl.json.key(POSIX 上 mode 0600),首次儲存時建立。

  • 高保證情境請從平台 keychain 衍生金鑰並明確傳入: UsbAcl(hmac_key=<bytes>)。否則同使用者身分的 process 可讀金鑰檔 而偽造簽章。

  • UsbAcl(require_signature=True) 可連 legacy 未簽章檔也一併拒絕。

啟動 host

host 需要 REST API 在跑(這樣 viewer 才能列舉),加上一條對 viewer 的 WebRTC peer connection(這樣 transfer 才能流動)。

REST:

from je_auto_control.utils.rest_api import start_rest_api_server
server = start_rest_api_server(host="0.0.0.0", port=9939)
print("Bearer:", server.token)

WebRTC:用既有的遠端桌面流程(見 維運與管理層)建立 session。viewer 端的 UsbPassthroughClient 之後就接到談妥的 DataChannel 上。

Viewer 端:claim 與 transfer

列舉

從 Python:

import urllib.request, json
req = urllib.request.Request(
    "http://host:9939/usb/devices",
    headers={"Authorization": f"Bearer {token}"},
)
with urllib.request.urlopen(req) as r:
    body = json.loads(r.read())
for d in body["devices"]:
    print(d["vendor_id"], d["product_id"], d.get("product"))

或用 viewer 端的 USB Browser GUI 分頁:貼上 host 的 REST URL + token,按 Fetch devices

Open + transfer

from je_auto_control.utils.usb.passthrough import (
    UsbPassthroughClient, encode_frame, decode_frame,
)

# `data_channel` 是你 WebRTC 上 "usb" channel 的 RTCDataChannel。
def send(frame):
    data_channel.send(encode_frame(frame))

client = UsbPassthroughClient(send_frame=send)
# 接上 channel 的 on-message callback:
data_channel.on("message")(lambda raw: client.feed_frame(decode_frame(raw)))

handle = client.open(vendor_id="1050", product_id="0407")
response = handle.control_transfer(
    bm_request_type=0xC0, b_request=6, w_value=0x0100, length=18,
)
print("device descriptor:", response.hex())
handle.close()
client.shutdown()

錯誤:

  • UsbClientTimeout — host 超過 reply_timeout_s(預設 10 秒) 沒回。檢查網路 / host 程序。

  • UsbClientError — host 回 {ok: false, error: ...}。最常見 情境是 denied by ACL policy — 去看 host 端的 prompt 對話框或 ACL 規則。

  • UsbClientClosed — client 或其 handle 已 shutdown。

疑難排解對照表

症狀

可能原因/處理

opendenied by ACL policy

沒有 allow rule 且 default = deny。加 rule 或啟用 prompt callback。

openno device matches

裝置沒被列舉。看 UsbHotplugWatcher 輸出或直接 跑 list_usb_devices()。Windows 上確認 Zadig 綁定。

transfer 上 credit exhausted

viewer 送的 frame 超過 host initial_credits 的 window。降低請求頻率或在 session 上提高 initial_credits

Transfer UsbClientTimeout

host 程序忙或 WebRTC channel 壞了。看 Packet Inspector 分頁的 RTT / 封包遺失。

OPEN 後 host 鍵盤停止運作

Linux:HID 裝置被 claim 且 usbhid 被 detach。 CLOSE 時 driver 會重新 attach;如果沒有,用 udevadm trigger 救回。

稽核鏈顯示 broken_at_id

有人直接編輯了 audit.db。從備份還原;調查。

無 GUI 控制(AC_usb_* 指令)

GUI 能做的事都有對應的 executor 指令,所以 JSON action 檔、socket server 與排程器都能在沒有 GUI 的情況下驅動 USB passthrough:

JSON action 範例:

[
  ["AC_usb_passthrough_enable", {"enabled": true}],
  ["AC_usb_acl_add", {"vendor_id": "1050", "product_id": "0407"}],
  ["AC_usb_loopback_open", {"vendor_id": "1050", "product_id": "0407"}]
]

同樣的操作另外提供兩個介面:

  • REST APIGET/POST /usb/passthrough/.../usb/acl.../usb/loopback/.../usb/remote/...(需 bearer token;見 /openapi.json)。ACL 匯入/匯出刻意 開 REST(伺服器端 檔案路徑風險)。

  • MCP — 一級 ac_usb_* 工具(ac_usb_loopback_open …),帶 JSON Schema,agent 可直接呼叫。

尚未發布的部分

  • USB 分享 分頁是簡易的 AnyDesk 風介面:左側啟用分享並對本機裝置 做 ACL 允許/封鎖;右側經 in-process channel 列出分享裝置並 開啟 其中一個(讀描述元即證明整條堆疊運作)。USB Browser 分頁的 Open 按鈕現在對 localhost 目標也會走同一條 loopback 路徑。

  • 跨機器已完整串接:WebRTC host 建立 usb DataChannel,viewer 以 viewer.usb_client() 暴露 UsbChannelClient(含 list_devices / open / resume)。USB 分享 面板有 來源 下拉:選 遠端(WebRTC) 時 List / Open 會對 live WebRTC viewer 的主機操作 (經 registry.webrtc_usb_client());選 本機(loopback) 則走同機。 亦可從 Python 驅動。

  • Windows WinUSB 與 macOS IOKit 的 transfer 路徑已寫但尚未對實體硬體 驗證。在 Phase 2e 硬體測試矩陣通過前請勿用於 production。

  • Phase 2e 外部安全審查尚未簽核;feature flag 必須維持顯式 opt-in。