diff --git a/config.go b/config.go index 32b3b659b..f432dc079 100644 --- a/config.go +++ b/config.go @@ -1,9 +1,11 @@ package kvm import ( + "crypto/sha256" "encoding/json" "fmt" "io" + "net" "os" "strconv" "strings" @@ -181,6 +183,20 @@ var ( } ) +// deriveNcmMACs derives two stable, locally-administered unicast MAC addresses +// from the device ID for the CDC-NCM function's host_addr and dev_addr. +// On dev builds GetDeviceID() may return "unknown_device_id"; the derivation +// still produces deterministic values, just shared across all such units. +func deriveNcmMACs(deviceID string) (hostMAC, devMAC string) { + h := sha256.Sum256([]byte(deviceID)) + host := net.HardwareAddr(append([]byte(nil), h[0:6]...)) + dev := net.HardwareAddr(append([]byte(nil), h[6:12]...)) + // locally-administered (bit 1 set), unicast (bit 0 cleared) on the first octet. + host[0] = (host[0] | 0x02) &^ 0x01 + dev[0] = (dev[0] | 0x02) &^ 0x01 + return host.String(), dev.String() +} + func getDefaultConfig() Config { return Config{ CloudURL: DefaultAPIURL, diff --git a/go.mod b/go.mod index f4388a4db..0e7060f04 100644 --- a/go.mod +++ b/go.mod @@ -10,11 +10,13 @@ require ( github.com/coder/websocket v1.8.14 github.com/coreos/go-oidc/v3 v3.16.0 github.com/creack/pty v1.1.24 + github.com/eclipse/paho.mqtt.golang v1.5.1 github.com/erikdubbelboer/gspt v0.0.0-20210805194459-ce36a5128377 github.com/fsnotify/fsnotify v1.9.0 github.com/gin-contrib/logger v1.2.6 github.com/gin-gonic/gin v1.10.1 github.com/go-co-op/gocron/v2 v2.17.0 + github.com/google/nftables v0.3.0 github.com/google/uuid v1.6.0 github.com/guregu/null/v6 v6.0.0 github.com/gwatts/rootcerts v0.0.0-20250901182336-dc5ae18bd79f @@ -54,7 +56,6 @@ require ( github.com/cloudwego/base64x v0.1.6 // indirect github.com/creack/goselect v0.1.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/eclipse/paho.mqtt.golang v1.5.1 // indirect github.com/gabriel-vasile/mimetype v1.4.9 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-jose/go-jose/v4 v4.1.3 // indirect @@ -62,6 +63,7 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.27.0 // indirect github.com/goccy/go-json v0.10.5 // indirect + github.com/google/go-cmp v0.7.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/jonboulle/clockwork v0.5.0 // indirect github.com/josharian/native v1.1.0 // indirect @@ -70,8 +72,9 @@ require ( github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect github.com/mdlayher/packet v1.1.2 // indirect - github.com/mdlayher/socket v0.4.1 // indirect + github.com/mdlayher/socket v0.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect diff --git a/go.sum b/go.sum index 4223acb59..76be198c3 100644 --- a/go.sum +++ b/go.sum @@ -74,6 +74,8 @@ github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/nftables v0.3.0 h1:bkyZ0cbpVeMHXOrtlFc8ISmfVqq5gPJukoYieyVmITg= +github.com/google/nftables v0.3.0/go.mod h1:BCp9FsrbF1Fn/Yu6CLUc9GGZFw/+hsxfluNXXmxBfRM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= @@ -117,10 +119,12 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mdlayher/ndp v1.1.0 h1:QylGKGVtH60sKZUE88+IW5ila1Z/M9/OXhWdsVKuscs= github.com/mdlayher/ndp v1.1.0/go.mod h1:FmgESgemgjl38vuOIyAHWUUL6vQKA/pQNkvXdWsdQFM= +github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg= +github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42/go.mod h1:BB4YCPDOzfy7FniQ/lxuYQ3dgmM2cZumHbK8RpTjN2o= github.com/mdlayher/packet v1.1.2 h1:3Up1NG6LZrsgDVn6X4L9Ge/iyRyxFEFD9o6Pr3Q1nQY= github.com/mdlayher/packet v1.1.2/go.mod h1:GEu1+n9sG5VtiRE4SydOmX5GTwyyYlteZiFU+x0kew4= -github.com/mdlayher/socket v0.4.1 h1:eM9y2/jlbs1M615oshPQOHZzj6R6wMT7bX5NPiQvn2U= -github.com/mdlayher/socket v0.4.1/go.mod h1:cAqeGjoufqdxWkD7DkpyS+wcefOtmu5OQ8KuoJGIReA= +github.com/mdlayher/socket v0.5.0 h1:ilICZmJcQz70vrWVes1MFera4jGiWNocSkykwwoy3XI= +github.com/mdlayher/socket v0.5.0/go.mod h1:WkcBFfvyG8QENs5+hfQPl1X6Jpd2yeLIYgrGFmJiJxI= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= diff --git a/internal/usbgadget/config.go b/internal/usbgadget/config.go index 6af97b04e..f87963437 100644 --- a/internal/usbgadget/config.go +++ b/internal/usbgadget/config.go @@ -65,24 +65,35 @@ var defaultGadgetConfig = map[string]gadgetConfigItem{ "mass_storage_lun0": massStorageLun0Config, // serial console (CDC-ACM) "serial_console": serialConsoleConfig, + // CDC-NCM (Ethernet over USB) + "ncm": ncmConfig, } func (u *UsbGadget) isGadgetConfigItemEnabled(itemKey string) bool { + return isGadgetConfigItemEnabledForDevices(itemKey, &u.enabledDevices) +} + +// isGadgetConfigItemEnabledForDevices reports whether a gadget config item is +// enabled for an arbitrary Devices selection. Items without an explicit case +// (base, base_info, wake_hid, audio, ...) are always enabled. +func isGadgetConfigItemEnabledForDevices(itemKey string, devices *Devices) bool { switch itemKey { case "absolute_mouse": - return u.enabledDevices.AbsoluteMouse + return devices.AbsoluteMouse case "relative_mouse": - return u.enabledDevices.RelativeMouse + return devices.RelativeMouse case "keyboard": - return u.enabledDevices.Keyboard + return devices.Keyboard case "mass_storage_base": - return u.enabledDevices.MassStorage + return devices.MassStorage case "mass_storage_lun0": - return u.enabledDevices.MassStorage + return devices.MassStorage case "serial_console": - return u.enabledDevices.SerialConsole + return devices.SerialConsole case "audio": - return u.enabledDevices.Audio + return devices.Audio + case "ncm": + return devices.Ncm default: return true } @@ -100,6 +111,13 @@ func (u *UsbGadget) loadGadgetConfig() { u.configMap["base_info"].attrs["serialnumber"] = u.customConfig.SerialNumber u.configMap["base_info"].attrs["manufacturer"] = u.customConfig.Manufacturer u.configMap["base_info"].attrs["product"] = u.customConfig.Product + + if u.customConfig.NcmHostMAC != "" { + u.configMap["ncm"].attrs["host_addr"] = u.customConfig.NcmHostMAC + } + if u.customConfig.NcmDevMAC != "" { + u.configMap["ncm"].attrs["dev_addr"] = u.customConfig.NcmDevMAC + } } func (u *UsbGadget) SetGadgetConfig(config *Config) { @@ -210,7 +228,7 @@ func (u *UsbGadget) UpdateGadgetConfig() error { } func (u *UsbGadget) configureUsbGadget(resetUsb bool) error { - return u.WithTransaction(func() error { + if err := u.WithTransaction(func() error { u.tx.MountConfigFS() u.tx.CreateConfigPath() u.tx.WriteGadgetConfig() @@ -218,5 +236,16 @@ func (u *UsbGadget) configureUsbGadget(resetUsb bool) error { u.tx.RebindUsb(true) } return nil - }) + }); err != nil { + return err + } + + if u.enabledDevices.Ncm { + if err := u.bringUpNcmInterface(); err != nil { + u.log.Warn().Err(err).Msg("failed to bring up NCM interface") + } + } else { + u.tearDownNcmInterface() + } + return nil } diff --git a/internal/usbgadget/endpoints.go b/internal/usbgadget/endpoints.go new file mode 100644 index 000000000..a769883f4 --- /dev/null +++ b/internal/usbgadget/endpoints.go @@ -0,0 +1,68 @@ +package usbgadget + +// USB endpoint budget for the JetKVM RV1106 dwc3 controller. +// +// dwc3 exposes IN (device->host) and OUT (host->device) endpoints as separate +// hardware resources, so they are budgeted independently — allocating a +// bulk-OUT never steals from the IN pool. +// +// The IN budget of 7 is empirically confirmed: the full default function set +// plus CDC-NCM needs 8 IN endpoints and NCM's bulk-IN silently fails to +// allocate (the link enumerates and usb0 comes up RX-only, TX is a black +// hole); dropping any single IN-using function (-> 7) makes it work. The OUT +// pool on this part is symmetric with IN and OUT demand stays far below it in +// practice, so the same ceiling is used for both. +// +// Note: the true low-level limiter for IN endpoints may be dwc3's TX-FIFO +// SRAM (each IN endpoint reserves FIFO space sized to its max packet) rather +// than a raw endpoint count, but the effective ceiling we observed is 7, so a +// simple count captures it. FIFO RAM is not modeled separately. +const ( + usbInEndpointBudget uint = 7 + usbOutEndpointBudget uint = 7 +) + +// endpointCost is the number of USB IN and OUT endpoints a gadget function +// consumes. +type endpointCost struct { + in uint + out uint +} + +// endpointCosts maps a gadget config item key to its endpoint cost. Keys match +// entries in defaultGadgetConfig; items absent here cost nothing (base, +// base_info, mass_storage_lun0, ...). HID OUT cost follows the function's +// no_out_endpoint attribute (keyboard has an interrupt-OUT for LED reports; +// the mice and wake HID do not). +var endpointCosts = map[string]endpointCost{ + "keyboard": {in: 1, out: 1}, // interrupt IN + interrupt OUT (LED reports) + "wake_hid": {in: 1, out: 0}, // interrupt IN only + "absolute_mouse": {in: 1, out: 0}, // interrupt IN only + "relative_mouse": {in: 1, out: 0}, // interrupt IN only + "audio": {in: 1, out: 0}, // UAC1 capture: isochronous IN + "mass_storage_base": {in: 1, out: 1}, // bulk IN + bulk OUT + "serial_console": {in: 2, out: 1}, // CDC-ACM: bulk IN + notify IN + bulk OUT + "ncm": {in: 2, out: 1}, // CDC-NCM: bulk IN + notify IN + bulk OUT +} + +// endpointUsage returns the IN and OUT endpoint demand of a device selection, +// including always-on functions (wake_hid) as well as toggleable ones. +func endpointUsage(devices *Devices) (in, out uint) { + for key, cost := range endpointCosts { + if isGadgetConfigItemEnabledForDevices(key, devices) { + in += cost.in + out += cost.out + } + } + return in, out +} + +// ExceedsEndpointBudget reports whether the given device selection would exceed +// the controller's IN or OUT endpoint budget. An over-budget gadget still +// enumerates but leaves some function's endpoint(s) silently unallocated (e.g. +// CDC-NCM comes up RX-only with a dead TX path), so the UI uses this to warn +// before the user commits to the combination. +func ExceedsEndpointBudget(devices *Devices) bool { + in, out := endpointUsage(devices) + return in > usbInEndpointBudget || out > usbOutEndpointBudget +} diff --git a/internal/usbgadget/ncm.go b/internal/usbgadget/ncm.go new file mode 100644 index 000000000..a5ab08646 --- /dev/null +++ b/internal/usbgadget/ncm.go @@ -0,0 +1,15 @@ +package usbgadget + +// ncmConfig declares the CDC-NCM (Ethernet over USB) function. +// host_addr / dev_addr are placeholders overridden at runtime from +// customConfig.NcmHostMAC and customConfig.NcmDevMAC in loadGadgetConfig. +var ncmConfig = gadgetConfigItem{ + order: 5000, + device: "ncm.usb0", + path: []string{"functions", "ncm.usb0"}, + configPath: []string{"ncm.usb0"}, + attrs: gadgetAttributes{ + "host_addr": "02:00:00:00:00:01", + "dev_addr": "02:00:00:00:00:02", + }, +} diff --git a/internal/usbgadget/ncm_firewall.go b/internal/usbgadget/ncm_firewall.go new file mode 100644 index 000000000..2e00bcef2 --- /dev/null +++ b/internal/usbgadget/ncm_firewall.go @@ -0,0 +1,97 @@ +package usbgadget + +import ( + "fmt" + "os/exec" + + "github.com/google/nftables" + "github.com/google/nftables/expr" + "golang.org/x/sys/unix" +) + +const ( + ncmFirewallTableName = "jetkvm" + ncmFirewallChainName = "input_usb0" +) + +// applyNcmFirewall installs (or replaces) an nftables table that drops all +// inbound TCP and UDP arriving on usb0. ICMP/ICMPv6 are intentionally not +// touched so NDP and ping continue to work — host isolation, not full +// blackhole. Idempotent: deletes any pre-existing table of the same name +// first so a stale ruleset from a previous run can't accumulate. +// +// Loads nf_tables.ko on first call via modprobe; the rv1106 rootfs ships +// the module but does not auto-load it. +func (u *UsbGadget) applyNcmFirewall() error { + if out, err := exec.Command("modprobe", "nf_tables").CombinedOutput(); err != nil { + return fmt.Errorf("modprobe nf_tables: %w: %s", err, out) + } + + conn, err := nftables.New() + if err != nil { + return fmt.Errorf("open nftables conn: %w", err) + } + + // Wipe any stale table from a previous run before building fresh. + table := &nftables.Table{Name: ncmFirewallTableName, Family: nftables.TableFamilyINet} + conn.DelTable(table) + // Ignore error — table may not exist, which is fine. + _ = conn.Flush() + + table = conn.AddTable(table) + policy := nftables.ChainPolicyAccept + chain := conn.AddChain(&nftables.Chain{ + Name: ncmFirewallChainName, + Table: table, + Hooknum: nftables.ChainHookInput, + Priority: nftables.ChainPriorityFilter, + Type: nftables.ChainTypeFilter, + Policy: &policy, + }) + + // One drop rule per L4 protocol. Each rule matches: + // iifname == usb0 AND l4proto == => drop + for _, proto := range []byte{unix.IPPROTO_TCP, unix.IPPROTO_UDP} { + conn.AddRule(&nftables.Rule{ + Table: table, + Chain: chain, + Exprs: []expr.Any{ + &expr.Meta{Key: expr.MetaKeyIIFNAME, Register: 1}, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: ifnameBytes(ncmInterfaceName)}, + &expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1}, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{proto}}, + &expr.Verdict{Kind: expr.VerdictDrop}, + }, + }) + } + + if err := conn.Flush(); err != nil { + return fmt.Errorf("commit nftables ruleset: %w", err) + } + return nil +} + +// removeNcmFirewall deletes our nftables table. Best-effort: a missing table +// is not an error (we may be called during teardown after a crash or during +// rapid toggle off/on cycles). +func (u *UsbGadget) removeNcmFirewall() { + conn, err := nftables.New() + if err != nil { + u.log.Warn().Err(err).Msg("nftables open failed during teardown") + return + } + conn.DelTable(&nftables.Table{Name: ncmFirewallTableName, Family: nftables.TableFamilyINet}) + if err := conn.Flush(); err != nil { + // Most likely cause is "table not found", which we don't care about. + u.log.Debug().Err(err).Msg("nftables flush during teardown") + } +} + +// ifnameBytes pads or truncates name to IFNAMSIZ (16 bytes), the form nft +// expects when comparing against the iifname meta key. A shorter slice +// silently fails to match (the kernel memcmps the full register width). +func ifnameBytes(name string) []byte { + b := make([]byte, unix.IFNAMSIZ) + copy(b, name) + return b +} diff --git a/internal/usbgadget/ncm_iface.go b/internal/usbgadget/ncm_iface.go new file mode 100644 index 000000000..69b1c20fe --- /dev/null +++ b/internal/usbgadget/ncm_iface.go @@ -0,0 +1,54 @@ +package usbgadget + +import ( + "errors" + "fmt" + "os" + + "github.com/vishvananda/netlink" +) + +const ncmInterfaceName = "usb0" + +// bringUpNcmInterface brings usb0 up. IPv6 link-local (fe80::/10) is +// auto-assigned by the kernel from the dev_addr MAC via modified EUI-64. +// Returns nil (not an error) if the netdev doesn't exist yet — the kernel +// creates it asynchronously after UDC bind, so the caller may retry. +func (u *UsbGadget) bringUpNcmInterface() error { + link, err := netlink.LinkByName(ncmInterfaceName) + if err != nil { + var lnf netlink.LinkNotFoundError + if errors.As(err, &lnf) { + return nil + } + return fmt.Errorf("lookup %s: %w", ncmInterfaceName, err) + } + + // Guard against a sysctl override leaving IPv6 disabled on this interface; + // the IPv6 link-local fe80:: is our only reachability path. + _ = os.WriteFile("/proc/sys/net/ipv6/conf/"+ncmInterfaceName+"/disable_ipv6", []byte("0"), 0644) + + if err := netlink.LinkSetUp(link); err != nil { + return fmt.Errorf("link up %s: %w", ncmInterfaceName, err) + } + + // Install the host-isolation firewall before returning success. Fail closed: + // if the firewall can't be installed, usb0 must not be exposed. + if err := u.applyNcmFirewall(); err != nil { + // Roll back the link so we don't leak an unfiltered interface. + _ = netlink.LinkSetDown(link) + return fmt.Errorf("apply NCM firewall: %w", err) + } + return nil +} + +// tearDownNcmInterface removes the firewall and brings usb0 down before the +// gadget rebind drops the netdev. Both steps are best-effort. +func (u *UsbGadget) tearDownNcmInterface() { + u.removeNcmFirewall() + link, err := netlink.LinkByName(ncmInterfaceName) + if err != nil { + return + } + _ = netlink.LinkSetDown(link) +} diff --git a/internal/usbgadget/usbgadget.go b/internal/usbgadget/usbgadget.go index bb4a9f98a..5c26f3d66 100644 --- a/internal/usbgadget/usbgadget.go +++ b/internal/usbgadget/usbgadget.go @@ -22,6 +22,7 @@ type Devices struct { MassStorage bool `json:"mass_storage"` SerialConsole bool `json:"serial_console"` Audio bool `json:"audio"` + Ncm bool `json:"ncm"` } // Config is a struct that represents the customizations for a USB gadget. @@ -33,6 +34,10 @@ type Config struct { Manufacturer string `json:"manufacturer"` Product string `json:"product"` + // Derived at boot from the device serial; not persisted. + NcmHostMAC string `json:"-"` + NcmDevMAC string `json:"-"` + strictMode bool // when it's enabled, all warnings will be converted to errors isEmpty bool } diff --git a/jsonrpc.go b/jsonrpc.go index 7a60656e1..b300eec0b 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -979,6 +979,20 @@ func rpcGetUsbDevices() (usbgadget.Devices, error) { return *config.UsbDevices, nil } +// UsbEndpointReport tells the UI whether a given device selection would exceed +// the controller's USB endpoint budget (IN or OUT). Over-budget combinations +// leave a function silently non-functional — notably CDC-NCM, which needs two +// IN endpoints, comes up RX-only with a dead TX path. +type UsbEndpointReport struct { + ExceedsBudget bool `json:"exceedsBudget"` +} + +func rpcGetUsbEndpointReport(devices usbgadget.Devices) (UsbEndpointReport, error) { + return UsbEndpointReport{ + ExceedsBudget: usbgadget.ExceedsEndpointBudget(&devices), + }, nil +} + func updateUsbRelatedConfig() error { if err := gadget.UpdateGadgetConfig(); err != nil { return fmt.Errorf("failed to write gadget config: %w", err) @@ -1021,6 +1035,8 @@ func rpcSetUsbDeviceState(device string, enabled bool) error { if !enabled { config.AudioEnabled = false } + case "ncm": + config.UsbDevices.Ncm = enabled default: return fmt.Errorf("invalid device: %s", device) } @@ -1422,6 +1438,7 @@ var rpcHandlers = map[string]RPCHandler{ "deleteSerialCommandHistory": {Func: rpcDeleteSerialCommandHistory}, "setTerminalPaused": {Func: rpcSetTerminalPaused, Params: []string{"terminalPaused"}}, "getUsbDevices": {Func: rpcGetUsbDevices}, + "getUsbEndpointReport": {Func: rpcGetUsbEndpointReport, Params: []string{"devices"}}, "setUsbDevices": {Func: rpcSetUsbDevices, Params: []string{"devices"}}, "setUsbDeviceState": {Func: rpcSetUsbDeviceState, Params: []string{"device", "enabled"}}, "setCloudUrl": {Func: rpcSetCloudUrl, Params: []string{"apiUrl", "appUrl"}}, diff --git a/ui/src/components/UsbDeviceSetting.tsx b/ui/src/components/UsbDeviceSetting.tsx index 5452922a5..d1ea335f9 100644 --- a/ui/src/components/UsbDeviceSetting.tsx +++ b/ui/src/components/UsbDeviceSetting.tsx @@ -27,6 +27,7 @@ export interface UsbDeviceConfig { mass_storage: boolean; serial_console: boolean; audio: boolean; + ncm: boolean; } const defaultUsbDeviceConfig: UsbDeviceConfig = { @@ -36,6 +37,7 @@ const defaultUsbDeviceConfig: UsbDeviceConfig = { mass_storage: true, serial_console: false, audio: true, + ncm: false, }; const usbPresets = [ @@ -49,6 +51,7 @@ const usbPresets = [ mass_storage: true, serial_console: false, audio: true, + ncm: false, }, }, { @@ -61,6 +64,7 @@ const usbPresets = [ mass_storage: false, serial_console: false, audio: false, + ncm: false, }, }, { @@ -76,6 +80,7 @@ export function UsbDeviceSetting() { const [usbDeviceConfig, setUsbDeviceConfig] = useState(defaultUsbDeviceConfig); const [selectedPreset, setSelectedPreset] = useState("default"); + const [overBudget, setOverBudget] = useState(false); const syncUsbDeviceConfig = useCallback(() => { send("getUsbDevices", {}, (resp: JsonRpcResponse) => { @@ -161,6 +166,17 @@ export function UsbDeviceSetting() { syncUsbDeviceConfig(); }, [syncUsbDeviceConfig]); + // Check whether the currently-selected function set fits the controller's USB + // endpoint budget. The dwc3 controller has a limited number of IN/OUT + // endpoints; an over-budget combination can leave a function (notably + // CDC-NCM) silently non-functional. + useEffect(() => { + send("getUsbEndpointReport", { devices: usbDeviceConfig }, (resp: JsonRpcResponse) => { + if ("error" in resp) return; + setOverBudget((resp.result as { exceedsBudget: boolean }).exceedsBudget); + }); + }, [send, usbDeviceConfig]); + return (
@@ -255,7 +271,23 @@ export function UsbDeviceSetting() { />
+
+ + + +
+ {overBudget && ( +
+ This combination exceeds the device's available USB endpoints. Functions beyond + the limit may silently fail to work — Ethernet over USB (CDC-NCM) in particular + will appear connected but won't pass traffic. Disable a function (e.g. Relative + Mouse) to free an endpoint. +
+ )}