From 07cf1eee6f5e9b146bded04b104c101709be718f Mon Sep 17 00:00:00 2001 From: Maurus Cuelenaere Date: Sun, 17 May 2026 12:34:33 +0200 Subject: [PATCH 1/3] feat(usbgadget): add CDC-NCM (Ethernet over USB) function Adds a new USB gadget function exposing CDC-NCM so the target host sees a USB Ethernet device. Off by default; toggled via setUsbDeviceState("ncm", true) or the new "Enable Ethernet over USB (CDC-NCM)" checkbox under the custom USB devices preset. host_addr and dev_addr are derived deterministically from GetDeviceID() via SHA-256, with the locally-administered bit set, so MACs stay stable across reboots and never collide across devices on the same host. Reachability uses IPv6 link-local only: as soon as the link comes up, the kernel auto-assigns fe80::/10 from the dev_addr MAC via modified EUI-64. This works zero-config on Windows, macOS, and Linux (the host's NM/NetCfg brings the new netdev up; on minimal Linux setups `ip link set up` is needed once). IPv4 link-local (APIPA) is intentionally not configured here -- it requires an extra address on our side and host-side fallback support that varies by distro; defer to a follow-up if needed. Bring-up/teardown of the usb0 netdev runs as a post-transaction hook in configureUsbGadget and uses vishvananda/netlink (already a dependency via pkg/nmlite/link) rather than shelling out to `ip`. Scope is deliberately minimal: no NAT, no DHCP server, no internet sharing, no bridge mode, no other Ethernet protocols. This is the base layer that future work (clipboard sync over IP, file transfer, etc.) can build on. Co-Authored-By: Claude Opus 4.7 (1M context) --- config.go | 16 +++++++++ internal/usbgadget/config.go | 26 +++++++++++++-- internal/usbgadget/ncm.go | 15 +++++++++ internal/usbgadget/ncm_iface.go | 45 ++++++++++++++++++++++++++ internal/usbgadget/usbgadget.go | 5 +++ jsonrpc.go | 2 ++ ui/src/components/UsbDeviceSetting.tsx | 12 +++++++ usb.go | 2 ++ 8 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 internal/usbgadget/ncm.go create mode 100644 internal/usbgadget/ncm_iface.go 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/internal/usbgadget/config.go b/internal/usbgadget/config.go index 6af97b04e..cb5700812 100644 --- a/internal/usbgadget/config.go +++ b/internal/usbgadget/config.go @@ -65,6 +65,8 @@ 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 { @@ -83,6 +85,8 @@ func (u *UsbGadget) isGadgetConfigItemEnabled(itemKey string) bool { return u.enabledDevices.SerialConsole case "audio": return u.enabledDevices.Audio + case "ncm": + return u.enabledDevices.Ncm default: return true } @@ -100,6 +104,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 +221,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 +229,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/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_iface.go b/internal/usbgadget/ncm_iface.go new file mode 100644 index 000000000..565228733 --- /dev/null +++ b/internal/usbgadget/ncm_iface.go @@ -0,0 +1,45 @@ +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) + } + return nil +} + +// tearDownNcmInterface brings usb0 down before the gadget rebind drops the +// netdev. Silent no-op if the interface is already gone. +func (u *UsbGadget) tearDownNcmInterface() { + 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..31cfa3647 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -1021,6 +1021,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) } diff --git a/ui/src/components/UsbDeviceSetting.tsx b/ui/src/components/UsbDeviceSetting.tsx index 5452922a5..1ab1f34d1 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, }, }, { @@ -255,6 +259,14 @@ 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. +
+ )}