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 ? (
+
+ + + +