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.
This commit is contained in:
Adam Shiervani
2026-03-27 23:09:39 +01:00
committed by GitHub
parent 711158ab18
commit edaa86c0d3
15 changed files with 314 additions and 8 deletions
+65
View File
@@ -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")
})
}
+4
View File
@@ -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
}
+8
View File
@@ -0,0 +1,8 @@
package usbgadget
var serialConsoleConfig = gadgetConfigItem{
order: 4000,
device: "acm.usb0",
path: []string{"functions", "acm.usb0"},
configPath: []string{"acm.usb0"},
}
+1
View File
@@ -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.
+2
View File
@@ -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)
}
+1
View File
@@ -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")
+143
View File
@@ -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
// ═══════════════════════════════════════════
+2
View File
@@ -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",
+42 -3
View File
@@ -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"
>
<div className="relative flex flex-wrap items-center gap-x-2 gap-y-2">
{developerMode && (
{developerMode && usbSerialConsoleEnabled ? (
<SplitButtonGroup>
<SplitButtonPrimary
icon={({ className }) => <CommandLineIcon className={className} />}
label={m.kvm_terminal()}
onClick={() => setTerminalType(terminalType === "kvm" ? "none" : "kvm")}
/>
<SplitButtonCaret
menuItems={[
{
label: "USB Serial Console",
icon: LuTerminal,
onClick: () => setTerminalType(terminalType === "cdcacm" ? "none" : "cdcacm"),
active: terminalType === "cdcacm",
},
]}
/>
</SplitButtonGroup>
) : developerMode ? (
<Button
size="XS"
theme="light"
@@ -93,7 +124,15 @@ export default function Actionbar({
LeadingIcon={({ className }) => <CommandLineIcon className={className} />}
onClick={() => setTerminalType(terminalType === "kvm" ? "none" : "kvm")}
/>
)}
) : usbSerialConsoleEnabled ? (
<Button
size="XS"
theme="light"
text="USB Serial Console"
LeadingIcon={LuTerminal}
onClick={() => setTerminalType(terminalType === "cdcacm" ? "none" : "cdcacm")}
/>
) : null}
<Popover>
<SplitButtonGroup>
<PopoverButton
+1
View File
@@ -74,6 +74,7 @@ export function SplitButtonCaret({ menuItems }: { menuItems: SplitButtonMenuItem
lightHover,
"inline-flex cursor-pointer items-center rounded-r-sm px-1",
"border-l-slate-800/15 dark:border-l-slate-300/10",
"disabled:pointer-events-none disabled:opacity-50",
)}
>
<ChevronDownIcon className="size-3.5 text-black dark:text-white" />
+3 -3
View File
@@ -144,7 +144,7 @@ function Terminal({
);
const onDataHandler = instance.onData(data => {
if (type === "kvm") {
if (type === "kvm" || type === "cdcacm") {
dataChannel.send(data);
} else {
if (data === "\r") {
@@ -170,8 +170,8 @@ function Terminal({
}
});
// Send initial terminal size
if (dataChannel.readyState === "open") {
// Send initial terminal size (not applicable for CDC-ACM raw serial)
if (dataChannel.readyState === "open" && type !== "cdcacm") {
if (type === "kvm") {
dataChannel.send(JSON.stringify({ rows: instance.rows, cols: instance.cols }));
} else {
+19 -1
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from "react";
import { JsonRpcResponse, useJsonRpc } from "@hooks/useJsonRpc";
import { useUiStore } from "@hooks/stores";
import { m } from "@localizations/messages.js";
import { SettingsItem } from "@components/SettingsItem";
import Checkbox from "@components/Checkbox";
@@ -24,6 +25,7 @@ export interface UsbDeviceConfig {
absolute_mouse: boolean;
relative_mouse: boolean;
mass_storage: boolean;
serial_console: boolean;
}
const defaultUsbDeviceConfig: UsbDeviceConfig = {
@@ -31,6 +33,7 @@ const defaultUsbDeviceConfig: UsbDeviceConfig = {
absolute_mouse: true,
relative_mouse: true,
mass_storage: true,
serial_console: false,
};
const usbPresets = [
@@ -42,6 +45,7 @@ const usbPresets = [
absolute_mouse: true,
relative_mouse: true,
mass_storage: true,
serial_console: false,
},
},
{
@@ -52,6 +56,7 @@ const usbPresets = [
absolute_mouse: false,
relative_mouse: false,
mass_storage: false,
serial_console: false,
},
},
{
@@ -63,6 +68,7 @@ const usbPresets = [
export function UsbDeviceSetting() {
const { send } = useJsonRpc();
const [loading, setLoading] = useState(false);
const { setUsbSerialConsoleEnabled } = useUiStore();
const [usbDeviceConfig, setUsbDeviceConfig] = useState<UsbDeviceConfig>(defaultUsbDeviceConfig);
const [selectedPreset, setSelectedPreset] = useState<string>("default");
@@ -77,6 +83,7 @@ export function UsbDeviceSetting() {
} else {
const usbConfigState = resp.result as UsbDeviceConfig;
setUsbDeviceConfig(usbConfigState);
setUsbSerialConsoleEnabled(usbConfigState.serial_console);
// Set the appropriate preset based on current config
const matchingPreset = usbPresets.find(
@@ -93,7 +100,7 @@ export function UsbDeviceSetting() {
setSelectedPreset(matchingPreset ? matchingPreset.value : "custom");
}
});
}, [send]);
}, [send, setUsbSerialConsoleEnabled]);
const handleUsbConfigChange = useCallback(
(devices: UsbDeviceConfig) => {
@@ -219,6 +226,17 @@ export function UsbDeviceSetting() {
/>
</SettingsItem>
</div>
<div className="space-y-4">
<SettingsItem
title={m.usb_device_enable_serial_console_title()}
description={m.usb_device_enable_serial_console_description()}
>
<Checkbox
checked={usbDeviceConfig.serial_console}
onChange={onUsbConfigItemChange("serial_console")}
/>
</SettingsItem>
</div>
</div>
<div className="mt-6 flex gap-x-2">
<Button
+7 -1
View File
@@ -40,7 +40,7 @@ const appendStatToMap = <T extends { timestamp: number }>(
// Constants and types
export type AvailableSidebarViews = "connection-stats";
export type AvailableTerminalTypes = "kvm" | "serial" | "none";
export type AvailableTerminalTypes = "kvm" | "serial" | "cdcacm" | "none";
export interface User {
sub: string;
@@ -78,6 +78,9 @@ export interface UIState {
isOcrMode: boolean;
setOcrMode: (enabled: boolean) => void;
usbSerialConsoleEnabled: boolean;
setUsbSerialConsoleEnabled: (enabled: boolean) => void;
}
export const useUiStore = create<UIState>(set => ({
@@ -111,6 +114,9 @@ export const useUiStore = create<UIState>(set => ({
isOcrMode: false,
setOcrMode: (enabled: boolean) => set({ isOcrMode: enabled }),
usbSerialConsoleEnabled: false,
setUsbSerialConsoleEnabled: (enabled: boolean) => set({ usbSerialConsoleEnabled: enabled }),
}));
export interface RTCState {
+14
View File
@@ -868,6 +868,16 @@ export default function KvmIdRoute() {
}
}, [peerConnection, serialConsole]);
// CDC-ACM console data channel
const [cdcACMConsole, setCdcACMConsole] = useState<RTCDataChannel | null>(null);
useEffect(() => {
if (!peerConnection) return;
if (!cdcACMConsole) {
setCdcACMConsole(peerConnection.createDataChannel("cdcacm"));
}
}, [peerConnection, cdcACMConsole]);
// Register E2E test hooks
useEffect(() => {
registerTestHandlers({
@@ -1060,6 +1070,10 @@ export default function KvmIdRoute() {
{serialConsole && (
<Terminal type="serial" dataChannel={serialConsole} title={m.serial_console()} />
)}
{cdcACMConsole && (
<Terminal type="cdcacm" dataChannel={cdcACMConsole} title="USB Serial Console" />
)}
</FeatureFlagProvider>
);
}
+2
View File
@@ -356,6 +356,8 @@ func newSession(config SessionConfig) (*Session, error) {
handleTerminalChannel(d)
case "serial":
handleSerialChannel(d)
case "cdcacm":
handleCDCACMChannel(d)
default:
if strings.HasPrefix(d.Label(), uploadIdPrefix) {
go handleUploadChannel(d)