From c088534d34c8b4ee08a6c3f1e1fda43aac6a74cf Mon Sep 17 00:00:00 2001 From: Siyuan Miao Date: Sun, 9 Mar 2025 07:59:23 +0100 Subject: [PATCH] feat(usb): dynamic usb devices config --- config.go | 18 + jsonrpc.go | 52 ++- ui/src/components/UsbDeviceSetting.tsx | 130 +++++++ .../routes/devices.$id.settings.hardware.tsx | 5 + usb.go | 354 +++++++++++------- 5 files changed, 420 insertions(+), 139 deletions(-) create mode 100644 ui/src/components/UsbDeviceSetting.tsx diff --git a/config.go b/config.go index e4e27d7..ba38a60 100644 --- a/config.go +++ b/config.go @@ -20,6 +20,13 @@ type UsbConfig struct { Product string `json:"product"` } +type UsbDevicesConfig struct { + AbsoluteMouse bool `json:"absolute_mouse"` + RelativeMouse bool `json:"relative_mouse"` + Keyboard bool `json:"keyboard"` + MassStorage bool `json:"mass_storage"` +} + type Config struct { CloudURL string `json:"cloud_url"` CloudAppURL string `json:"cloud_app_url"` @@ -38,6 +45,7 @@ type Config struct { DisplayDimAfterSec int `json:"display_dim_after_sec"` DisplayOffAfterSec int `json:"display_off_after_sec"` UsbConfig *UsbConfig `json:"usb_config"` + UsbDevices *UsbDevicesConfig `json:"usb_devices"` } const configPath = "/userdata/kvm_config.json" @@ -57,6 +65,12 @@ var defaultConfig = &Config{ Manufacturer: "JetKVM", Product: "USB Emulation Device", }, + UsbDevices: &UsbDevicesConfig{ + AbsoluteMouse: true, + RelativeMouse: true, + Keyboard: true, + MassStorage: true, + }, } var ( @@ -95,6 +109,10 @@ func LoadConfig() { loadedConfig.UsbConfig = defaultConfig.UsbConfig } + if loadedConfig.UsbDevices == nil { + loadedConfig.UsbDevices = defaultConfig.UsbDevices + } + config = &loadedConfig } diff --git a/jsonrpc.go b/jsonrpc.go index 40be85c..d512362 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -544,19 +544,7 @@ func rpcGetUsbConfig() (UsbConfig, error) { func rpcSetUsbConfig(usbConfig UsbConfig) error { LoadConfig() config.UsbConfig = &usbConfig - - err := UpdateGadgetConfig() - if err != nil { - return fmt.Errorf("failed to write gadget config: %w", err) - } - - err = SaveConfig() - if err != nil { - return fmt.Errorf("failed to save usb config: %w", err) - } - - log.Printf("[jsonrpc.go:rpcSetUsbConfig] usb config set to %s", usbConfig) - return nil + return updateUsbRelatedConfig() } func rpcGetWakeOnLanDevices() ([]WakeOnLanDevice, error) { @@ -751,6 +739,41 @@ func rpcSetSerialSettings(settings SerialSettings) error { return nil } +func rpcGetUsbDevices() (UsbDevicesConfig, error) { + return *config.UsbDevices, nil +} + +func updateUsbRelatedConfig() error { + if err := UpdateGadgetConfig(); err != nil { + return fmt.Errorf("failed to write gadget config: %w", err) + } + if err := SaveConfig(); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + return nil +} + +func rpcSetUsbDevices(usbDevices UsbDevicesConfig) error { + config.UsbDevices = &usbDevices + return updateUsbRelatedConfig() +} + +func rpcSetUsbDeviceState(device string, enabled bool) error { + switch device { + case "absoluteMouse": + config.UsbDevices.AbsoluteMouse = enabled + case "relativeMouse": + config.UsbDevices.RelativeMouse = enabled + case "keyboard": + config.UsbDevices.Keyboard = enabled + case "massStorage": + config.UsbDevices.MassStorage = enabled + default: + return fmt.Errorf("invalid device: %s", device) + } + return updateUsbRelatedConfig() +} + func rpcSetCloudUrl(apiUrl string, appUrl string) error { config.CloudURL = apiUrl config.CloudAppURL = appUrl @@ -831,6 +854,9 @@ var rpcHandlers = map[string]RPCHandler{ "setATXPowerAction": {Func: rpcSetATXPowerAction, Params: []string{"action"}}, "getSerialSettings": {Func: rpcGetSerialSettings}, "setSerialSettings": {Func: rpcSetSerialSettings, Params: []string{"settings"}}, + "getUsbDevices": {Func: rpcGetUsbDevices}, + "setUsbDevices": {Func: rpcSetUsbDevices, Params: []string{"devices"}}, + "setUsbDeviceState": {Func: rpcSetUsbDeviceState, Params: []string{"device", "enabled"}}, "setCloudUrl": {Func: rpcSetCloudUrl, Params: []string{"apiUrl", "appUrl"}}, "getScrollSensitivity": {Func: rpcGetScrollSensitivity}, "setScrollSensitivity": {Func: rpcSetScrollSensitivity, Params: []string{"sensitivity"}}, diff --git a/ui/src/components/UsbDeviceSetting.tsx b/ui/src/components/UsbDeviceSetting.tsx new file mode 100644 index 0000000..9509203 --- /dev/null +++ b/ui/src/components/UsbDeviceSetting.tsx @@ -0,0 +1,130 @@ +import { useCallback } from "react"; + +import { useEffect, useState } from "react"; +import { useJsonRpc } from "../hooks/useJsonRpc"; +import notifications from "../notifications"; +import { SettingsItem } from "../routes/devices.$id.settings"; +import Checkbox from "./Checkbox"; + +export interface USBConfig { + vendor_id: string; + product_id: string; + serial_number: string; + manufacturer: string; + product: string; +} + +export interface UsbDeviceConfig { + keyboard: boolean; + absolute_mouse: boolean; + relative_mouse: boolean; + mass_storage: boolean; +} + +const defaultUsbDeviceConfig: UsbDeviceConfig = { + keyboard: true, + absolute_mouse: true, + relative_mouse: true, + mass_storage: true, +} + +export function UsbDeviceSetting() { + const [send] = useJsonRpc(); + + const [usbDeviceConfig, setUsbDeviceConfig] = useState(defaultUsbDeviceConfig); + const syncUsbDeviceConfig = useCallback(() => { + send("getUsbDevices", {}, resp => { + if ("error" in resp) { + console.error("Failed to load USB devices:", resp.error); + notifications.error( + `Failed to load USB devices: ${resp.error.data || "Unknown error"}`, + ); + } else { + console.log("syncUsbDeviceConfig#getUsbDevices result:", resp.result); + const usbConfigState = resp.result as UsbDeviceConfig; + setUsbDeviceConfig(usbConfigState); + } + }); + }, [send]); + + const handleUsbConfigChange = useCallback( + (devices: UsbDeviceConfig) => { + send("setUsbDevices", { devices }, resp => { + if ("error" in resp) { + notifications.error( + `Failed to set usb devices: ${resp.error.data || "Unknown error"}`, + ); + return; + } + // setUsbConfigProduct(usbConfig.product); + notifications.success( + `USB Devices updated` + ); + syncUsbDeviceConfig(); + }); + }, + [send, syncUsbDeviceConfig], + ); + + const onUsbConfigItemChange = useCallback((key: keyof UsbDeviceConfig) => (e: React.ChangeEvent) => { + setUsbDeviceConfig((val) => { + val[key] = e.target.checked; + handleUsbConfigChange(val); + return val; + }); + }, [handleUsbConfigChange]); + + useEffect(() => { + syncUsbDeviceConfig(); + }, [syncUsbDeviceConfig]); + + return ( + <> +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+ + ); +} diff --git a/ui/src/routes/devices.$id.settings.hardware.tsx b/ui/src/routes/devices.$id.settings.hardware.tsx index 5fb744b..3a60466 100644 --- a/ui/src/routes/devices.$id.settings.hardware.tsx +++ b/ui/src/routes/devices.$id.settings.hardware.tsx @@ -7,6 +7,7 @@ import { useJsonRpc } from "@/hooks/useJsonRpc"; import notifications from "../notifications"; import { SelectMenuBasic } from "@components/SelectMenuBasic"; import { UsbConfigSetting } from "../components/UsbConfigSetting"; +import { UsbDeviceSetting } from "@components/UsbDeviceSetting"; import { FeatureFlag } from "../components/FeatureFlag"; export default function SettingsHardwareRoute() { @@ -132,6 +133,10 @@ export default function SettingsHardwareRoute() { + + + +
); } diff --git a/usb.go b/usb.go index 1318d09..0fdc906 100644 --- a/usb.go +++ b/usb.go @@ -1,6 +1,7 @@ package kvm import ( + "bytes" "errors" "fmt" "log" @@ -20,6 +21,114 @@ const gadgetPath = "/sys/kernel/config/usb_gadget" const kvmGadgetPath = "/sys/kernel/config/usb_gadget/jetkvm" const configC1Path = "/sys/kernel/config/usb_gadget/jetkvm/configs/c.1" +func (u *UsbDevicesConfig) isGadgetConfigItemEnabled(itemKey string) bool { + switch itemKey { + case "absolute_mouse": + return u.AbsoluteMouse + case "relative_mouse": + return u.RelativeMouse + case "keyboard": + return u.Keyboard + case "mass_storage_base": + return u.MassStorage + case "mass_storage_usb0": + return u.MassStorage + default: + return true + } +} + +type gadgetConfigItem struct { + device string + path []string + attrs gadgetAttributes + configAttrs gadgetAttributes + configPath string + reportDesc []byte +} + +type gadgetAttributes map[string]string + +var gadgetConfig = map[string]gadgetConfigItem{ + "base": { + attrs: gadgetAttributes{ + "bcdUSB": "0x0200", // USB 2.0 + "idVendor": "0x1d6b", // The Linux Foundation + "idProduct": "0104", // Multifunction Composite Gadget¬ + "bcdDevice": "0100", + }, + configAttrs: gadgetAttributes{ + "MaxPower": "250", // in unit of 2mA + }, + }, + "base_info": { + path: []string{"strings", "0x409"}, + attrs: gadgetAttributes{ + "serialnumber": GetDeviceID(), + "manufacturer": "JetKVM", + "product": "JetKVM USB Emulation Device", + }, + configAttrs: gadgetAttributes{ + "configuration": "Config 1: HID", + }, + }, + // keyboard HID + "keyboard": { + device: "hid.usb0", + path: []string{"functions", "hid.usb0"}, + configPath: path.Join(configC1Path, "hid.usb0"), + attrs: gadgetAttributes{ + "protocol": "1", + "subclass": "1", + "report_length": "8", + }, + reportDesc: KeyboardReportDesc, + }, + // mouse HID + "absolute_mouse": { + device: "hid.usb1", + path: []string{"functions", "hid.usb1"}, + configPath: path.Join(configC1Path, "hid.usb1"), + attrs: gadgetAttributes{ + "protocol": "2", + "subclass": "1", + "report_length": "6", + }, + reportDesc: CombinedAbsoluteMouseReportDesc, + }, + // relative mouse HID + "relative_mouse": { + device: "hid.usb2", + path: []string{"functions", "hid.usb2"}, + configPath: path.Join(configC1Path, "hid.usb2"), + attrs: gadgetAttributes{ + "protocol": "2", + "subclass": "1", + "report_length": "4", + }, + reportDesc: CombinedRelativeMouseReportDesc, + }, + // mass storage + "mass_storage_base": { + device: "mass_storage.usb0", + path: []string{"functions", "mass_storage.usb0"}, + configPath: path.Join(configC1Path, "mass_storage.usb0"), + attrs: gadgetAttributes{ + "stall": "1", + }, + }, + "mass_storage_usb0": { + path: []string{"functions", "mass_storage.usb0", "lun.0"}, + attrs: gadgetAttributes{ + "cdrom": "1", + "ro": "1", + "removable": "1", + "file": "\n", + "inquiry_string": "JetKVM Virtual Media", + }, + }, +} + func mountConfigFS() error { _, err := os.Stat(gadgetPath) if os.IsNotExist(err) { @@ -33,6 +142,109 @@ func mountConfigFS() error { return nil } +func writeIfDifferent(filePath string, content []byte, permMode os.FileMode) error { + if _, err := os.Stat(filePath); err == nil { + oldContent, err := os.ReadFile(filePath) + if err == nil { + if bytes.Equal(oldContent, content) { + logger.Tracef("skipping writing to %s as it already has the correct content", filePath) + return nil + } + + if len(oldContent) == len(content)+1 && + bytes.Equal(oldContent[:len(content)], content) && + oldContent[len(content)] == 10 { + logger.Tracef("skipping writing to %s as it already has the correct content", filePath) + return nil + } + + logger.Tracef("writing to %s as it has different content %v %v", filePath, oldContent, content) + } + } + return os.WriteFile(filePath, content, permMode) +} + +func disableGadgetItemConfig(item gadgetConfigItem) error { + // remove symlink if exists + if item.configPath != "" { + if _, err := os.Lstat(item.configPath); os.IsNotExist(err) { + logger.Tracef("symlink %s does not exist", item.configPath) + } else { + err := os.Remove(item.configPath) + if err != nil { + return fmt.Errorf("failed to remove symlink %s: %w", item.configPath, err) + } + } + } + + return nil +} + +func writeGadgetItemConfig(item gadgetConfigItem) error { + // create directory for the item + gadgetItemPathArr := append([]string{kvmGadgetPath}, item.path...) + gadgetItemPath := filepath.Join(gadgetItemPathArr...) + err := os.MkdirAll(gadgetItemPath, 0755) + if err != nil { + return fmt.Errorf("failed to create path %s: %w", gadgetItemPath, err) + } + + if len(item.configAttrs) > 0 { + configItemPathArr := append([]string{configC1Path}, item.path...) + configItemPath := filepath.Join(configItemPathArr...) + err = os.MkdirAll(configItemPath, 0755) + if err != nil { + return fmt.Errorf("failed to create path %s: %w", config, err) + } + + err = writeGadgetAttrs(configItemPath, item.configAttrs) + if err != nil { + return fmt.Errorf("failed to write config attributes for %s: %w", configItemPath, err) + } + } + + if len(item.attrs) > 0 { + // write attributes for the item + err = writeGadgetAttrs(gadgetItemPath, item.attrs) + if err != nil { + return fmt.Errorf("failed to write attributes for %s: %w", gadgetItemPath, err) + } + } + + // write report descriptor if available + if item.reportDesc != nil { + err = writeIfDifferent(path.Join(gadgetItemPath, "report_desc"), item.reportDesc, 0644) + if err != nil { + return err + } + } + + // create symlink if configPath is set + if item.configPath != "" { + logger.Tracef("Creating symlink from %s to %s", item.configPath, gadgetItemPath) + + // check if the symlink already exists, if yes, check if it points to the correct path + if _, err := os.Lstat(item.configPath); err == nil { + linkPath, err := os.Readlink(item.configPath) + if err != nil || linkPath != gadgetItemPath { + err = os.Remove(item.configPath) + if err != nil { + return fmt.Errorf("failed to remove existing symlink %s: %w", item.configPath, err) + } + } + } else if !os.IsNotExist(err) { + return fmt.Errorf("failed to check if symlink exists: %w", err) + } + + err = os.Symlink(gadgetItemPath, item.configPath) + if err != nil { + return fmt.Errorf("failed to create symlink from %s to %s: %w", item.configPath, gadgetItemPath, err) + } + } + + return nil +} + func init() { ensureConfigLoaded() @@ -119,132 +331,22 @@ func writeGadgetConfig() error { return err } - err = writeGadgetAttrs(kvmGadgetPath, [][]string{ - {"bcdUSB", "0x0200"}, //USB 2.0 - {"idVendor", config.UsbConfig.VendorId}, //The Linux Foundation - {"idProduct", config.UsbConfig.ProductId}, //Multifunction Composite Gadget¬ - {"bcdDevice", "0100"}, - }) - if err != nil { - return err - } - - gadgetStringsPath := filepath.Join(kvmGadgetPath, "strings", "0x409") - err = os.MkdirAll(gadgetStringsPath, 0755) - if err != nil { - return err - } - - err = writeGadgetAttrs(gadgetStringsPath, [][]string{ - {"serialnumber", GetDeviceID()}, - {"manufacturer", config.UsbConfig.Manufacturer}, - {"product", config.UsbConfig.Product}, - }) - if err != nil { - return err - } - - configC1StringsPath := path.Join(configC1Path, "strings", "0x409") - err = os.MkdirAll(configC1StringsPath, 0755) - if err != nil { - return err - } - - err = writeGadgetAttrs(configC1Path, [][]string{ - {"MaxPower", "250"}, //in unit of 2mA - }) - if err != nil { - return err - } - - err = writeGadgetAttrs(configC1StringsPath, [][]string{ - {"configuration", "Config 1: HID"}, - }) - if err != nil { - return err - } - - //keyboard HID - hid0Path := path.Join(kvmGadgetPath, "functions", "hid.usb0") - err = os.MkdirAll(hid0Path, 0755) - if err != nil { - return err - } - err = writeGadgetAttrs(hid0Path, [][]string{ - {"protocol", "1"}, - {"subclass", "1"}, - {"report_length", "8"}, - }) - if err != nil { - return err - } - - err = os.WriteFile(path.Join(hid0Path, "report_desc"), KeyboardReportDesc, 0644) - if err != nil { - return err - } - - //mouse HID - hid1Path := path.Join(kvmGadgetPath, "functions", "hid.usb1") - err = os.MkdirAll(hid1Path, 0755) - if err != nil { - return err - } - err = writeGadgetAttrs(hid1Path, [][]string{ - {"protocol", "2"}, - {"subclass", "1"}, - {"report_length", "6"}, - }) - if err != nil { - return err - } - - err = os.WriteFile(path.Join(hid1Path, "report_desc"), CombinedMouseReportDesc, 0644) - if err != nil { - return err - } - //mass storage - massStoragePath := path.Join(kvmGadgetPath, "functions", "mass_storage.usb0") - err = os.MkdirAll(massStoragePath, 0755) - if err != nil { - return err - } - - err = writeGadgetAttrs(massStoragePath, [][]string{ - {"stall", "1"}, - }) - if err != nil { - return err - } - lun0Path := path.Join(massStoragePath, "lun.0") - err = os.MkdirAll(lun0Path, 0755) - if err != nil { - return err - } - err = writeGadgetAttrs(lun0Path, [][]string{ - {"cdrom", "1"}, - {"ro", "1"}, - {"removable", "1"}, - {"file", "\n"}, - {"inquiry_string", "JetKVM Virtual Media"}, - }) - if err != nil { - return err - } - - err = os.Symlink(hid0Path, path.Join(configC1Path, "hid.usb0")) - if err != nil { - return err - } - - err = os.Symlink(hid1Path, path.Join(configC1Path, "hid.usb1")) - if err != nil { - return err - } - - err = os.Symlink(massStoragePath, path.Join(configC1Path, "mass_storage.usb0")) - if err != nil { - return err + logger.Tracef("writing gadget config") + for key, item := range gadgetConfig { + // check if the item is enabled in the config + if !config.UsbDevices.isGadgetConfigItemEnabled(key) { + logger.Tracef("disabling gadget config: %s", key) + err = disableGadgetItemConfig(item) + if err != nil { + return err + } + continue + } + logger.Tracef("writing gadget config: %s", key) + err = writeGadgetItemConfig(item) + if err != nil { + return err + } } err = os.WriteFile(path.Join(kvmGadgetPath, "UDC"), []byte(udc), 0644)