From edaa86c0d3b360fa1dde1cd161406766bc84fd39 Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Fri, 27 Mar 2026 23:09:39 +0100 Subject: [PATCH] feat: add USB CDC-ACM serial console gadget (#726) (#1352) * fix: add USB serial console toggle to hardware settings (#726) * fix: add USB CDC-ACM serial console gadget function (#726) Add serial_console.go with acm.usb0 gadget config item following the mass_storage pattern. Add SerialConsole bool to Devices struct and wire it through config.go enable check and jsonrpc.go setUsbDeviceState. The existing UI toggle in UsbDeviceSetting.tsx (with localization messages) now calls through to the backend correctly. When enabled, the KVM device creates /dev/ttyGS0 and the target host sees a CDC-ACM serial device (/dev/ttyACM*). When disabled, the symlink is removed from the USB gadget config and the host no longer enumerates the ACM interface. * fix: add CDC-ACM Console terminal UI for USB serial gadget (#726) * fix: merge terminal buttons into split button and rename CDC-ACM to USB Serial Console (#726) Combine KVM Terminal and USB Serial Console into a split button when both are present, make USB serial console state reactive via zustand store so the action bar updates without a page refresh, and fix the split button chevron not respecting the disabled state. --- cdc_acm_console.go | 65 +++++++++++ internal/usbgadget/config.go | 4 + internal/usbgadget/serial_console.go | 8 ++ internal/usbgadget/usbgadget.go | 1 + jsonrpc.go | 2 + log.go | 1 + ui/e2e/remote-agent/ra-all.spec.ts | 143 +++++++++++++++++++++++++ ui/localization/messages/en.json | 2 + ui/src/components/ActionBar.tsx | 45 +++++++- ui/src/components/SplitButton.tsx | 1 + ui/src/components/Terminal.tsx | 6 +- ui/src/components/UsbDeviceSetting.tsx | 20 +++- ui/src/hooks/stores.ts | 8 +- ui/src/routes/devices.$id.tsx | 14 +++ webrtc.go | 2 + 15 files changed, 314 insertions(+), 8 deletions(-) create mode 100644 cdc_acm_console.go create mode 100644 internal/usbgadget/serial_console.go diff --git a/cdc_acm_console.go b/cdc_acm_console.go new file mode 100644 index 00000000..ae907a16 --- /dev/null +++ b/cdc_acm_console.go @@ -0,0 +1,65 @@ +package kvm + +import ( + "io" + "os" + + "github.com/pion/webrtc/v4" +) + +const cdcACMDevicePath = "/dev/ttyGS0" + +func handleCDCACMChannel(d *webrtc.DataChannel) { + scopedLogger := cdcACMLogger.With(). + Uint16("data_channel_id", *d.ID()).Logger() + + var f *os.File + d.OnOpen(func() { + var err error + f, err = os.OpenFile(cdcACMDevicePath, os.O_RDWR, 0) + if err != nil { + scopedLogger.Warn().Err(err).Str("path", cdcACMDevicePath).Msg("Failed to open CDC-ACM device") + d.Close() + return + } + + go func() { + buf := make([]byte, 1024) + for { + n, err := f.Read(buf) + if err != nil { + if err != io.EOF { + scopedLogger.Warn().Err(err).Msg("Failed to read from CDC-ACM device") + } + break + } + if err := d.Send(buf[:n]); err != nil { + scopedLogger.Warn().Err(err).Msg("Failed to send CDC-ACM output") + break + } + } + }() + + scopedLogger.Info().Msg("CDC-ACM console channel opened") + }) + + d.OnMessage(func(msg webrtc.DataChannelMessage) { + if f == nil { + return + } + if _, err := f.Write(msg.Data); err != nil { + scopedLogger.Warn().Err(err).Msg("Failed to write to CDC-ACM device") + } + }) + + d.OnClose(func() { + if f != nil { + f.Close() + } + scopedLogger.Info().Msg("CDC-ACM console channel closed") + }) + + d.OnError(func(err error) { + scopedLogger.Warn().Err(err).Msg("CDC-ACM console channel error") + }) +} diff --git a/internal/usbgadget/config.go b/internal/usbgadget/config.go index 6d1bd391..30969dd1 100644 --- a/internal/usbgadget/config.go +++ b/internal/usbgadget/config.go @@ -59,6 +59,8 @@ var defaultGadgetConfig = map[string]gadgetConfigItem{ // mass storage "mass_storage_base": massStorageBaseConfig, "mass_storage_lun0": massStorageLun0Config, + // serial console (CDC-ACM) + "serial_console": serialConsoleConfig, } func (u *UsbGadget) isGadgetConfigItemEnabled(itemKey string) bool { @@ -73,6 +75,8 @@ func (u *UsbGadget) isGadgetConfigItemEnabled(itemKey string) bool { return u.enabledDevices.MassStorage case "mass_storage_lun0": return u.enabledDevices.MassStorage + case "serial_console": + return u.enabledDevices.SerialConsole default: return true } diff --git a/internal/usbgadget/serial_console.go b/internal/usbgadget/serial_console.go new file mode 100644 index 00000000..b6d3df01 --- /dev/null +++ b/internal/usbgadget/serial_console.go @@ -0,0 +1,8 @@ +package usbgadget + +var serialConsoleConfig = gadgetConfigItem{ + order: 4000, + device: "acm.usb0", + path: []string{"functions", "acm.usb0"}, + configPath: []string{"acm.usb0"}, +} diff --git a/internal/usbgadget/usbgadget.go b/internal/usbgadget/usbgadget.go index 3c76b42a..1d73cdf3 100644 --- a/internal/usbgadget/usbgadget.go +++ b/internal/usbgadget/usbgadget.go @@ -20,6 +20,7 @@ type Devices struct { RelativeMouse bool `json:"relative_mouse"` Keyboard bool `json:"keyboard"` MassStorage bool `json:"mass_storage"` + SerialConsole bool `json:"serial_console"` } // Config is a struct that represents the customizations for a USB gadget. diff --git a/jsonrpc.go b/jsonrpc.go index 1a70d592..33d6f6e3 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -907,6 +907,8 @@ func rpcSetUsbDeviceState(device string, enabled bool) error { config.UsbDevices.Keyboard = enabled case "massStorage": config.UsbDevices.MassStorage = enabled + case "serialConsole": + config.UsbDevices.SerialConsole = enabled default: return fmt.Errorf("invalid device: %s", device) } diff --git a/log.go b/log.go index f84b02f0..03489722 100644 --- a/log.go +++ b/log.go @@ -26,6 +26,7 @@ var ( otaLogger = logging.GetSubsystemLogger("ota") serialLogger = logging.GetSubsystemLogger("serial") terminalLogger = logging.GetSubsystemLogger("terminal") + cdcACMLogger = logging.GetSubsystemLogger("cdcacm") displayLogger = logging.GetSubsystemLogger("display") wolLogger = logging.GetSubsystemLogger("wol") usbLogger = logging.GetSubsystemLogger("usb") diff --git a/ui/e2e/remote-agent/ra-all.spec.ts b/ui/e2e/remote-agent/ra-all.spec.ts index 9386e386..f5e68b08 100644 --- a/ui/e2e/remote-agent/ra-all.spec.ts +++ b/ui/e2e/remote-agent/ra-all.spec.ts @@ -1101,6 +1101,149 @@ test.describe("Remote Host Agent", () => { }); }); + // ═══════════════════════════════════════════ + // USB SERIAL CONSOLE (CDC-ACM) + // ═══════════════════════════════════════════ + + test("usb: serial console CDC-ACM toggle creates and removes ttyACM on host", async () => { + test.setTimeout(30_000); + + const remoteHost = process.env.JETKVM_REMOTE_HOST; + test.skip(!remoteHost, "JETKVM_REMOTE_HOST not set"); + + const sshTarget = remoteHost!.includes("@") ? remoteHost! : `tony@${remoteHost}`; + const sshOpts = "-o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -o ConnectTimeout=10"; + const { execSync } = await import("child_process"); + + const remoteExec = (cmd: string) => + execSync(`ssh ${sshOpts} ${sshTarget} "${cmd}"`, { encoding: "utf8" }).trim(); + + // Ensure serial console is off initially + await callJsonRpc(sharedPage, "setUsbDevices", { + devices: { ...USB_DEVICES_DEFAULT, serial_console: false }, + }); + await new Promise(r => setTimeout(r, 3000)); + + // Verify the host does NOT see a ttyACM device + const beforeACM = remoteExec("ls /dev/ttyACM* 2>/dev/null || echo MISSING"); + expect(beforeACM).toBe("MISSING"); + + // Enable serial console + await callJsonRpc(sharedPage, "setUsbDevices", { + devices: { ...USB_DEVICES_DEFAULT, serial_console: true }, + }); + await new Promise(r => setTimeout(r, 3000)); + + // Verify the host now sees a ttyACM device + const afterACM = remoteExec("ls /dev/ttyACM* 2>/dev/null || echo MISSING"); + expect(afterACM).not.toBe("MISSING"); + + // Verify /dev/ttyGS0 exists on the KVM device + const afterGS0 = (await sshExec("ls /dev/ttyGS0 2>/dev/null || echo MISSING", true)).trim(); + expect(afterGS0).toBe("/dev/ttyGS0"); + + // Disable serial console + await callJsonRpc(sharedPage, "setUsbDevices", { + devices: { ...USB_DEVICES_DEFAULT, serial_console: false }, + }); + await new Promise(r => setTimeout(r, 3000)); + + // Verify the host no longer sees a ttyACM device + const removedACM = remoteExec("ls /dev/ttyACM* 2>/dev/null || echo MISSING"); + expect(removedACM).toBe("MISSING"); + + // Verify other USB functions still work (keyboard, mouse) + await agent!.waitForInputDevices( + ["keyboard", "absolute_mouse", "relative_mouse"], + 10000, + ); + }); + + // ═══════════════════════════════════════════ + // USB SERIAL CONSOLE UI + // ═══════════════════════════════════════════ + + test("usb: USB Serial Console terminal sends and receives data via ttyGS0", async () => { + test.setTimeout(60_000); + + const remoteHost = process.env.JETKVM_REMOTE_HOST; + test.skip(!remoteHost, "JETKVM_REMOTE_HOST not set"); + + const sshTarget = remoteHost!.includes("@") ? remoteHost! : `tony@${remoteHost}`; + const sshOpts = "-o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -o ConnectTimeout=10"; + const { execSync } = await import("child_process"); + + // Use single-quoted SSH commands to avoid nested quoting issues + const remoteExec = (cmd: string) => + execSync(`ssh ${sshOpts} ${sshTarget} '${cmd}'`, { encoding: "utf8", timeout: 15_000 }).trim(); + + // Enable serial console + await callJsonRpc(sharedPage, "setUsbDevices", { + devices: { ...USB_DEVICES_DEFAULT, serial_console: true }, + }); + await new Promise(r => setTimeout(r, 3000)); + + // Find the ttyACM device on the remote host + const ttyACM = remoteExec("ls /dev/ttyACM* 2>/dev/null | head -1"); + expect(ttyACM).toContain("ttyACM"); + + // Reload the page so the action bar picks up serial_console enabled state + await sharedPage.reload({ waitUntil: "networkidle" }); + await waitForWebRTCReady(sharedPage); + + // Verify the USB Serial Console button is visible + const cdcButton = sharedPage.getByRole("button", { name: "USB Serial Console" }); + await expect(cdcButton).toBeVisible({ timeout: 5000 }); + + // Click the button to open the terminal + await cdcButton.click(); + await new Promise(r => setTimeout(r, 1000)); + + // Configure the remote serial port and start a background reader + const testString = `e2e_test_${Date.now()}`; + remoteExec(`sudo stty -F ${ttyACM} 9600 raw -echo`); + remoteExec(`sudo bash -c "nohup cat ${ttyACM} > /tmp/cdcacm_rx.txt 2>/dev/null &"`); + await new Promise(r => setTimeout(r, 500)); + + // Type a string into the USB Serial Console terminal + // The terminal is focused after opening, so we type directly + await sharedPage.keyboard.type(testString, { delay: 50 }); + await new Promise(r => setTimeout(r, 2000)); + + // Read what the remote host received + const received = remoteExec("sudo cat /tmp/cdcacm_rx.txt 2>/dev/null || echo EMPTY"); + expect(received).toContain(testString); + + // Test receiving data: send from remote host to ttyACM + const replyString = `reply_${Date.now()}`; + remoteExec(`sudo bash -c "echo ${replyString} > ${ttyACM}"`); + await new Promise(r => setTimeout(r, 2000)); + + // Take a screenshot for visual review + await sharedPage.screenshot({ path: `${process.cwd()}/screenshot.png` }); + + // Clean up: kill background cat, remove temp file + remoteExec("sudo pkill -f cat.*/dev/ttyACM || true"); + remoteExec("sudo rm -f /tmp/cdcacm_rx.txt"); + + // Close the terminal + await sharedPage.keyboard.press("Escape"); + await new Promise(r => setTimeout(r, 500)); + + // Disable serial console to clean up + await callJsonRpc(sharedPage, "setUsbDevices", { + devices: { ...USB_DEVICES_DEFAULT, serial_console: false }, + }); + await new Promise(r => setTimeout(r, 2000)); + + // Verify button is gone after disabling + await sharedPage.reload({ waitUntil: "networkidle" }); + await waitForWebRTCReady(sharedPage); + await expect( + sharedPage.getByRole("button", { name: "USB Serial Console" }), + ).not.toBeVisible({ timeout: 5000 }); + }); + // ═══════════════════════════════════════════ // USB RECOVERY // ═══════════════════════════════════════════ diff --git a/ui/localization/messages/en.json b/ui/localization/messages/en.json index aafeb68b..dc6520d1 100644 --- a/ui/localization/messages/en.json +++ b/ui/localization/messages/en.json @@ -953,6 +953,8 @@ "usb_device_enable_mass_storage_title": "Enable USB Mass Storage", "usb_device_enable_relative_mouse_description": "Enable Relative Mouse", "usb_device_enable_relative_mouse_title": "Enable Relative Mouse", + "usb_device_enable_serial_console_description": "Exposes a USB serial (CDC-ACM) device to the target host", + "usb_device_enable_serial_console_title": "Enable USB Serial Console", "usb_device_failed_load": "Failed to load USB devices: {error}", "usb_device_failed_set": "Failed to set USB devices: {error}", "usb_device_keyboard_mouse_and_mass_storage": "Keyboard, Mouse and Mass Storage", diff --git a/ui/src/components/ActionBar.tsx b/ui/src/components/ActionBar.tsx index 7bde652c..ceff0d68 100644 --- a/ui/src/components/ActionBar.tsx +++ b/ui/src/components/ActionBar.tsx @@ -1,4 +1,4 @@ -import { Fragment, useCallback, useRef } from "react"; +import { Fragment, useCallback, useEffect, useRef } from "react"; import { useParams } from "react-router"; import { MdOutlineContentPasteGo } from "react-icons/md"; import { @@ -9,6 +9,7 @@ import { LuScanText, LuSettings, LuSignal, + LuTerminal, LuX, } from "react-icons/lu"; import { FaKeyboard } from "react-icons/fa6"; @@ -33,6 +34,7 @@ import PasteModal from "@components/popovers/PasteModal"; import WakeOnLanModal from "@components/popovers/WakeOnLan/Index"; import MountPopopover from "@components/popovers/MountPopover"; import ExtensionPopover from "@components/popovers/ExtensionPopover"; +import { JsonRpcResponse, useJsonRpc } from "@hooks/useJsonRpc"; import { m } from "@localizations/messages.js"; export default function Actionbar({ @@ -53,11 +55,22 @@ export default function Actionbar({ toggleSidebarView, isOcrMode, setOcrMode, + usbSerialConsoleEnabled, + setUsbSerialConsoleEnabled, } = useUiStore(); const { remoteVirtualMediaState } = useMountMediaStore(); const { width: videoWidth, height: videoHeight } = useVideoStore(); const { developerMode } = useSettingsStore(); const { openDetachedWindow } = useDetachedWindow(); + const { send } = useJsonRpc(); + + useEffect(() => { + send("getUsbDevices", {}, (resp: JsonRpcResponse) => { + if ("error" in resp) return; + const devices = resp.result as { serial_console?: boolean }; + setUsbSerialConsoleEnabled(devices.serial_console === true); + }); + }, [send, setUsbSerialConsoleEnabled]); // This is the only way to get a reliable state change for the popover // at time of writing this there is no mount, or unmount event for the popover @@ -85,7 +98,25 @@ export default function Actionbar({ className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 py-1.5" >
- {developerMode && ( + {developerMode && usbSerialConsoleEnabled ? ( + + } + label={m.kvm_terminal()} + onClick={() => setTerminalType(terminalType === "kvm" ? "none" : "kvm")} + /> + setTerminalType(terminalType === "cdcacm" ? "none" : "cdcacm"), + active: terminalType === "cdcacm", + }, + ]} + /> + + ) : developerMode ? (
+
+ + + +