This commit is contained in:
Kirill Makhonin 2025-04-10 12:00:56 -05:00 committed by GitHub
commit 5b4280eb50
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 1087 additions and 2 deletions

View File

@ -34,6 +34,10 @@ type Config struct {
TLSMode string `json:"tls_mode"`
UsbConfig *usbgadget.Config `json:"usb_config"`
UsbDevices *usbgadget.Devices `json:"usb_devices"`
// Remote KVM
RemoteKvmEnabled bool `json:"remote_kvm_enabled"`
RemoteKvmSelectedChannel string `json:"remote_kvm_selected_channel"`
RemoteKvmChannels []SwitchChannel `json:"remote_kvm_channels"`
}
const configPath = "/userdata/kvm_config.json"

View File

@ -737,6 +737,65 @@ func rpcGetUsbDevices() (usbgadget.Devices, error) {
return *config.UsbDevices, nil
}
func rpcGetKvmSwitchEnabled() (bool, error) {
return config.RemoteKvmEnabled, nil
}
func rpcSetKvmSwitchEnabled(enabled bool) error {
config.RemoteKvmEnabled = enabled
config.RemoteKvmSelectedChannel = ""
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
return nil
}
func rpcGetKvmSwitchSelectedChannel() (*SwitchChannel, error) {
if !config.RemoteKvmEnabled {
return nil, fmt.Errorf("KVM switch is disabled")
}
if config.RemoteKvmSelectedChannel == "" {
return nil, fmt.Errorf("no channel selected")
}
for _, c := range config.RemoteKvmChannels {
if c.Id == config.RemoteKvmSelectedChannel {
return &c, nil
}
}
return nil, fmt.Errorf("channel not found")
}
func rpcSetKvmSwitchSelectedChannel(id string) error {
// Check that the channel is known (exists in the config)
err := RemoteKvmSwitchChannel(id)
if err != nil {
return fmt.Errorf("unable to select channel by ID %s: %w", id, err)
}
config.RemoteKvmSelectedChannel = id
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
return nil
}
func rpcGetKvmSwitchChannels() ([]SwitchChannel, error) {
if !config.RemoteKvmEnabled {
return nil, fmt.Errorf("KVM switch is disabled")
}
return config.RemoteKvmChannels, nil
}
func rpcSetKvmSwitchChannels(newConfig SwitchChannelConfig) error {
config.RemoteKvmChannels = newConfig.Channels
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
return nil
}
func updateUsbRelatedConfig() error {
if err := gadget.UpdateGadgetConfig(); err != nil {
return fmt.Errorf("failed to write gadget config: %w", err)
@ -862,4 +921,11 @@ var rpcHandlers = map[string]RPCHandler{
"setCloudUrl": {Func: rpcSetCloudUrl, Params: []string{"apiUrl", "appUrl"}},
"getScrollSensitivity": {Func: rpcGetScrollSensitivity},
"setScrollSensitivity": {Func: rpcSetScrollSensitivity, Params: []string{"sensitivity"}},
// remote KVM
"getKvmSwitchEnabled": {Func: rpcGetKvmSwitchEnabled},
"setKvmSwitchEnabled": {Func: rpcSetKvmSwitchEnabled, Params: []string{"enabled"}},
"getKvmSwitchChannels": {Func: rpcGetKvmSwitchChannels},
"setKvmSwitchChannels": {Func: rpcSetKvmSwitchChannels, Params: []string{"config"}},
"getKvmSwitchSelectedChannel": {Func: rpcGetKvmSwitchSelectedChannel},
"setKvmSwitchSelectedChannel": {Func: rpcSetKvmSwitchSelectedChannel, Params: []string{"id"}},
}

264
remote_kvm.go Normal file
View File

@ -0,0 +1,264 @@
package kvm
import (
"bufio"
"bytes"
"encoding/base64"
"encoding/hex"
"fmt"
"io"
"net"
"net/http"
"strings"
)
// SwitchChannelCommandProtocol is the protocol used to connect to the remote KVM switch
type SwitchChannelCommandProtocol string
// SwitchChannelCommandFormat is the format of the commands (hex, base64, ascii)
type SwitchChannelCommandFormat string
const (
SwitchChannelCommandProtocolTCP SwitchChannelCommandProtocol = "tcp"
SwitchChannelCommandProtocolUDP SwitchChannelCommandProtocol = "udp"
SwitchChannelCommandProtocolHTTP SwitchChannelCommandProtocol = "http"
SwitchChannelCommandProtocolHTTPs SwitchChannelCommandProtocol = "https"
)
const (
SwitchChannelCommandFormatHEX SwitchChannelCommandFormat = "hex"
SwitchChannelCommandFormatBase64 SwitchChannelCommandFormat = "base64"
SwitchChannelCommandFormatASCII SwitchChannelCommandFormat = "ascii"
SwitchChannelCommandFormatHTTP SwitchChannelCommandFormat = "http-raw"
)
// SwitchChannelCommand represents a command to be sent to a remote KVM switch
type SwitchChannelCommand struct {
Address string `json:"address"`
Protocol SwitchChannelCommandProtocol `json:"protocol"`
Format SwitchChannelCommandFormat `json:"format"`
Commands string `json:"commands"`
}
// SwitchChannel represents a remote KVM switch channel
type SwitchChannel struct {
Commands []SwitchChannelCommand `json:"commands"`
Name string `json:"name"`
Id string `json:"id"`
}
// SwitchChannelConfig represents the remote KVM switch configuration
type SwitchChannelConfig struct {
Channels []SwitchChannel `json:"channels"`
}
func remoteKvmSwitchChannelRawIP(channel *SwitchChannel, idx int, command *SwitchChannelCommand) error {
var err error
var payloadBytes = make([][]byte, 0)
// Parse commands
switch command.Format {
case SwitchChannelCommandFormatHEX:
// Split by comma and parse as HEX
for _, cmd := range strings.Split(command.Commands, ",") {
// Trim spaces, remove 0x prefix and parse as HEX
commandText := strings.ToLower(strings.TrimPrefix(strings.TrimSpace(cmd), "0x"))
b, err := hex.DecodeString(commandText)
if err != nil {
return fmt.Errorf("invalid command provided for command #%d: %w", idx, err)
}
payloadBytes = append(payloadBytes, b)
}
break
case SwitchChannelCommandFormatBase64:
// Split by comma and parse as Base64
for _, cmd := range strings.Split(command.Commands, ",") {
// Parse Base64
b, err := base64.StdEncoding.DecodeString(strings.TrimSpace(cmd))
if err != nil {
return fmt.Errorf("invalid command provided for command #%d: %w", idx, err)
}
payloadBytes = append(payloadBytes, b)
}
break
case SwitchChannelCommandFormatASCII:
// Split by newline and parse as ASCII
for _, cmd := range strings.Split(command.Commands, "\n") {
// Parse ASCII
b := []byte(strings.TrimSpace(cmd))
payloadBytes = append(payloadBytes, b)
}
break
default:
return fmt.Errorf("invalid format provided for %s command #%d: %s", command.Protocol, idx, command.Format)
}
// Connect to the address
var conn net.Conn
switch command.Protocol {
case SwitchChannelCommandProtocolTCP:
conn, err = net.Dial("tcp", command.Address)
break
case SwitchChannelCommandProtocolUDP:
conn, err = net.Dial("udp", command.Address)
break
default:
return fmt.Errorf("invalid protocol provided for command #%d: %s", idx, command.Protocol)
}
if err != nil {
return fmt.Errorf("failed to connect to address for command #%d: %w", idx, err)
}
if conn == nil {
return fmt.Errorf("failed to connect to address for command #%d: connection is nil", idx)
}
defer func() {
if conn != nil {
_ = conn.Close()
}
}()
// Send commands
for _, b := range payloadBytes {
_, err := conn.Write(b)
if err != nil {
return fmt.Errorf("failed to send command for command #%d: %w", idx, err)
}
}
// Close the connection
err = conn.Close()
if err != nil {
return fmt.Errorf("failed to close connection for command #%d: %w", idx, err)
}
return nil
}
func remoteKvmSwitchChannelHttps(channel *SwitchChannel, idx int, command *SwitchChannelCommand) error {
var err error
// Validation
scheme := string(command.Protocol)
if scheme != "http" && scheme != "https" {
return fmt.Errorf("invalid protocol provided for command #%d: %s", idx, command.Protocol)
}
if command.Format != SwitchChannelCommandFormatHTTP {
return fmt.Errorf("invalid format provided for %s command #%d: %s", command.Protocol, idx, command.Format)
}
httpPayload := command.Commands
// If there is no \r\n at then end - add
if !strings.HasSuffix(httpPayload, "\r\n\r\n") {
if strings.HasSuffix(httpPayload, "\r\n") {
httpPayload += "\r\n"
} else {
httpPayload += "\r\n\r\n"
}
}
// Parse request
requestReader := bufio.NewReader(strings.NewReader(httpPayload))
r, err := http.ReadRequest(requestReader)
if err != nil {
return fmt.Errorf("failed to read request for command #%d: %w", idx, err)
}
r.RequestURI, r.URL.Scheme, r.URL.Host = "", scheme, r.Host
// Execute request
resp, err := http.DefaultClient.Do(r)
if err != nil {
return fmt.Errorf("failed to send request for command #%d: %w", idx, err)
}
// Read data to buffer
var buf bytes.Buffer
_, err = io.Copy(&buf, resp.Body)
if err != nil {
return fmt.Errorf("failed to read response for command #%d: %w", idx, err)
}
// Close the response
defer func() {
if resp != nil {
_ = resp.Body.Close()
}
}()
if resp.StatusCode >= 400 || resp.StatusCode < 200 {
if buf.Len() > 0 {
return fmt.Errorf("failed to send request for command #%d: %s: %s", idx, resp.Status, buf.String())
} else {
return fmt.Errorf("failed to send request for command #%d: %s", idx, resp.Status)
}
}
return nil
}
// RemoteKvmSwitchChannel sends commands to a remote KVM switch
func RemoteKvmSwitchChannel(id string) error {
if !config.RemoteKvmEnabled {
return fmt.Errorf("remote KVM is not enabled")
}
if len(config.RemoteKvmChannels) == 0 {
return fmt.Errorf("no remote KVM channels configured")
}
if len(id) == 0 {
return fmt.Errorf("no channel id provided")
}
var channel *SwitchChannel
for _, c := range config.RemoteKvmChannels {
if c.Id == id {
channel = &c
break
}
}
if channel == nil {
return fmt.Errorf("channel not found")
}
// Try to run commands
if len(channel.Commands) == 0 {
return fmt.Errorf("no commands found for channel %s", id)
}
for idx, c := range channel.Commands {
// Initial validation
if c.Protocol == SwitchChannelCommandProtocolTCP || c.Protocol == SwitchChannelCommandProtocolUDP {
if c.Address == "" {
return fmt.Errorf("no address provided for command #%d", idx)
}
_, _, err := net.SplitHostPort(c.Address)
if err != nil {
return fmt.Errorf("invalid address provided for command #%d: %w", idx, err)
}
}
if c.Protocol == "" {
return fmt.Errorf("no protocol provided for command #%d", idx)
}
if c.Format == "" {
return fmt.Errorf("no format provided for command #%d", idx)
}
if c.Commands == "" {
return fmt.Errorf("no commands provided for command #%d", idx)
}
switch {
case c.Protocol == SwitchChannelCommandProtocolTCP || c.Protocol == SwitchChannelCommandProtocolUDP:
return remoteKvmSwitchChannelRawIP(channel, idx, &c)
case c.Protocol == SwitchChannelCommandProtocolHTTPs || c.Protocol == SwitchChannelCommandProtocolHTTP:
return remoteKvmSwitchChannelHttps(channel, idx, &c)
default:
return fmt.Errorf("invalid protocol provided for command #%d: %s", idx, c.Protocol)
}
}
return nil
}

13
ui/package-lock.json generated
View File

@ -35,6 +35,7 @@
"recharts": "^2.15.0",
"tailwind-merge": "^2.5.5",
"usehooks-ts": "^3.1.0",
"uuid": "^11.1.0",
"validator": "^13.12.0",
"xterm": "^5.3.0",
"zustand": "^4.5.2"
@ -6217,6 +6218,18 @@
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
},
"node_modules/uuid": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"bin": {
"uuid": "dist/esm/bin/uuid"
}
},
"node_modules/validator": {
"version": "13.12.0",
"resolved": "https://registry.npmjs.org/validator/-/validator-13.12.0.tgz",

View File

@ -45,6 +45,7 @@
"recharts": "^2.15.0",
"tailwind-merge": "^2.5.5",
"usehooks-ts": "^3.1.0",
"uuid": "^11.1.0",
"validator": "^13.12.0",
"xterm": "^5.3.0",
"zustand": "^4.5.2"

View File

@ -1,8 +1,8 @@
import { MdOutlineContentPasteGo } from "react-icons/md";
import { LuCable, LuHardDrive, LuMaximize, LuSettings, LuSignal } from "react-icons/lu";
import { LuCable, LuHardDrive, LuMaximize, LuMerge, LuSettings, LuSignal } from "react-icons/lu";
import { FaKeyboard } from "react-icons/fa6";
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
import { Fragment, useCallback, useRef } from "react";
import { Fragment, useCallback, useEffect, useRef } from "react";
import { CommandLineIcon } from "@heroicons/react/20/solid";
import { Button } from "@components/Button";
@ -18,13 +18,16 @@ 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 SelectChannelPopover from "@/components/popovers/SelectChannelPopover";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
import { useJsonRpc } from "@/hooks/useJsonRpc";
export default function Actionbar({
requestFullscreen,
}: {
requestFullscreen: () => Promise<void>;
}) {
const [send] = useJsonRpc();
const { navigateTo } = useDeviceUiNavigation();
const virtualKeyboard = useHidStore(state => state.isVirtualKeyboardEnabled);
@ -33,6 +36,10 @@ export default function Actionbar({
const setDisableFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
const terminalType = useUiStore(state => state.terminalType);
const setTerminalType = useUiStore(state => state.setTerminalType);
const remoteKvmEnabled = useUiStore(state => state.remoteKvmEnabled);
const setRemoteKvmEnabled = useUiStore(state => state.setRemoteKvmEnabled);
const remoteKvmSelectedChannel = useUiStore(state => state.remoteKvmSelectedChannel);
const setRemoteKvmSelectedChannel = useUiStore(state => state.setRemoteKvmSelectedChannel);
const remoteVirtualMediaState = useMountMediaStore(
state => state.remoteVirtualMediaState,
);
@ -56,6 +63,28 @@ export default function Actionbar({
[setDisableFocusTrap],
);
useEffect(() => {
send("getKvmSwitchEnabled", {}, resp => {
if ("error" in resp) {
return
}
setRemoteKvmEnabled(Boolean(resp.result));
send("getKvmSwitchSelectedChannel", {}, resp => {
if ("error" in resp) {
return
}
const data = resp.result as any;
setRemoteKvmSelectedChannel({
name: data.name,
id: data.id
});
})
});
}, [send]);
return (
<Container className="border-b border-b-slate-800/20 bg-white dark:border-b-slate-300/20 dark:bg-slate-900">
<div
@ -207,6 +236,44 @@ export default function Actionbar({
onClick={() => setVirtualKeyboard(!virtualKeyboard)}
/>
</div>
{remoteKvmEnabled && (
<div>
<Popover>
<PopoverButton as={Fragment}>
<Button
size="XS"
theme="light"
text={!!remoteKvmSelectedChannel ? `Channel: ${remoteKvmSelectedChannel.name}` : "Select channel"}
LeadingIcon={LuMerge}
onClick={() => {
setDisableFocusTrap(true);
}}
/>
</PopoverButton>
<PopoverPanel
anchor="bottom start"
transition
style={{
transitionProperty: "opacity",
}}
className={cx(
"z-10 flex w-[420px] origin-top flex-col !overflow-visible",
"flex origin-top flex-col transition duration-300 ease-out data-[closed]:translate-y-8 data-[closed]:opacity-0",
)}
>
{({ open }) => {
checkIfStateChanged(open);
return (
<div className="mx-auto w-full max-w-xl">
<SelectChannelPopover />
</div>
);
}}
</PopoverPanel>
</Popover>
</div>
)}
</div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-2">

View File

@ -0,0 +1,101 @@
import { useCallback, useEffect, useState } from "react";
import { useClose } from "@headlessui/react";
import { GridCard } from "@components/Card";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import notifications from "@/notifications";
import { SelectMenuBasic } from "@components/SelectMenuBasic";
import { RemoteKVMSwitchSelectedChannel, useUiStore } from "@/hooks/stores";
export default function SelectChannelPopover() {
const [send] = useJsonRpc();
const [switchChannelNames, setSwitchChannelNames] = useState<RemoteKVMSwitchSelectedChannel[]>([]);
const remoteKvmSelectedChannel = useUiStore(state => state.remoteKvmSelectedChannel);
const setRemoteKvmSelectedChannel = useUiStore(state => state.setRemoteKvmSelectedChannel);
const close = useClose();
useEffect(() => {
send("getKvmSwitchChannels", {}, resp => {
if ("error" in resp) {
notifications.error(`Failed to get switch channels: ${resp.error.data || "Unknown error"}`);
return
}
setSwitchChannelNames((resp.result as { name: string, id: string }[]).map(x => ({ name: x.name, id: x.id })));
})
}, [send]);
const onChannelSelected = useCallback((selection: RemoteKVMSwitchSelectedChannel | null) => {
if (selection === null) {
close();
return;
}
let selectedItem = selection!;
send("setKvmSwitchSelectedChannel", { id: selectedItem.id }, resp => {
if ("error" in resp) {
notifications.error(`Failed to set switch channel: ${resp.error.data || "Unknown error"}`);
return
}
notifications.success(`Remote KVM switch set to channel ${selectedItem.name}`);
setRemoteKvmSelectedChannel(selectedItem);
close();
})
}, [send, close, setRemoteKvmSelectedChannel]);
const onChannelSelectedById = useCallback((id: string) => {
onChannelSelected(switchChannelNames.find(x => x.id === id) || null);
}, [onChannelSelected, switchChannelNames]);
let options = []
if (!remoteKvmSelectedChannel) {
options.push({
label: "Select Channel",
value: ""
});
}
if (switchChannelNames.length > 0) {
options = options.concat(switchChannelNames.map(x => ({ label: x.name, value: x.id })))
}
return (
<GridCard>
<div className="space-y-4 p-4 py-3">
<div className="grid h-full grid-rows-headerBody">
<div className="h-full space-y-4">
<div className="space-y-4">
<SettingsPageHeader
title="Select Channel"
description="Select channel on the remote KVM"
/>
<div
className="animate-fadeIn space-y-2 opacity-0"
style={{
animationDuration: "0.7s",
animationDelay: "0.1s",
}}
>
<div>
<SelectMenuBasic
value={remoteKvmSelectedChannel ? remoteKvmSelectedChannel.id : ""}
size="MD"
onChange={e => onChannelSelectedById(e.target.value)}
options={options}
fullWidth
/>
</div>
</div>
</div>
</div>
</div>
</div>
</GridCard>
);
}

View File

@ -34,6 +34,11 @@ interface UserState {
setUser: (user: User | null) => void;
}
export interface RemoteKVMSwitchSelectedChannel {
id: string;
name: string;
}
interface UIState {
sidebarView: AvailableSidebarViews | null;
setSidebarView: (view: AvailableSidebarViews | null) => void;
@ -51,6 +56,14 @@ interface UIState {
terminalType: AvailableTerminalTypes;
setTerminalType: (enabled: UIState["terminalType"]) => void;
remoteKvmEnabled: boolean;
setRemoteKvmEnabled: (enabled: boolean) => void;
remoteKvmSelectedChannel: RemoteKVMSwitchSelectedChannel | null;
setRemoteKvmSelectedChannel: (
channel: RemoteKVMSwitchSelectedChannel | null,
) => void;
}
export const useUiStore = create<UIState>(set => ({
@ -78,6 +91,12 @@ export const useUiStore = create<UIState>(set => ({
isAttachedVirtualKeyboardVisible: true,
setAttachedVirtualKeyboardVisibility: enabled =>
set({ isAttachedVirtualKeyboardVisible: enabled }),
remoteKvmEnabled: false,
setRemoteKvmEnabled: enabled => set({ remoteKvmEnabled: enabled }),
remoteKvmSelectedChannel: null,
setRemoteKvmSelectedChannel: channel => set({ remoteKvmSelectedChannel: channel }),
}));
interface RTCState {

View File

@ -33,6 +33,7 @@ import OtherSessionRoute from "./routes/devices.$id.other-session";
import MountRoute from "./routes/devices.$id.mount";
import * as SettingsRoute from "./routes/devices.$id.settings";
import SettingsKeyboardMouseRoute from "./routes/devices.$id.settings.mouse";
import SettingsSwitchRoute from "./routes/devices.$id.settings.switch";
import api from "./api";
import * as SettingsIndexRoute from "./routes/devices.$id.settings._index";
import SettingsAdvancedRoute from "./routes/devices.$id.settings.advanced";
@ -145,6 +146,10 @@ if (isOnDevice) {
path: "mouse",
element: <SettingsKeyboardMouseRoute />,
},
{
path: "switch",
element: <SettingsSwitchRoute />,
},
{
path: "advanced",
element: <SettingsAdvancedRoute />,
@ -253,6 +258,10 @@ if (isOnDevice) {
path: "mouse",
element: <SettingsKeyboardMouseRoute />,
},
{
path: "switch",
element: <SettingsSwitchRoute />,
},
{
path: "advanced",
element: <SettingsAdvancedRoute />,

View File

@ -0,0 +1,529 @@
import { useState, useEffect, Fragment, useCallback } from "react";
import { Button } from "@/components/Button";
import TextArea from "@/components/TextArea";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { MdOutlineDelete, MdOutlineNorth, MdOutlinePlusOne, MdOutlineSouth } from "react-icons/md";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import notifications from "../notifications";
import { SelectMenuBasic } from "../components/SelectMenuBasic";
import { SettingsItem } from "./devices.$id.settings";
import { Checkbox } from "@/components/Checkbox";
import { GridCard } from "@components/Card";
import InputField, { FieldError, InputFieldWithLabel } from "@components/InputField";
import FieldLabel from "@components/FieldLabel";
import { v4 as uuidv4 } from 'uuid'
/**
* Switch channel command definition (what should be sent if channel is selected)
*/
export interface SwitchChannelCommands {
// Remote address
address: string;
// Protocol to send data in
protocol: "tcp" | "udp" | "http" | "https";
// Command format
format: "hex" | "base64" | "ascii" | "http-raw";
// Comma separated commands
commands: string;
}
/**
* Switch channel definition
*/
export interface SwitchChannel {
name: string;
id: string;
commands: SwitchChannelCommands[];
}
export const CommandsTextHelp: Record<SwitchChannelCommands["format"], string> = {
hex: "Provide comma separated list of commands, e.g. 0x24,0x68,0xA40A",
base64: "Provide comma separated list of commands in base64, e.g. aGVsbG8=,d29ybGQ=",
ascii: "Provide newline separated list of commands, e.g. hello\nworld",
"http-raw": "Provide raw HTTP request, e.g. GET / HTTP/1.1",
}
export const GetCompatibleCommands = (protocol: SwitchChannelCommands["protocol"]) => {
if (protocol == "http" || protocol == "https") {
return ["http-raw"];
} else {
return ["hex", "base64", "ascii"];
}
}
export const CommandsTextPlaceholder: Record<SwitchChannelCommands["format"], string> = {
hex: "0x24,0x68,0xA4",
base64: "aGVsbG8=,d29ybGQ=",
ascii: "hello\nworld",
"http-raw": `GET /images HTTP/1.1
Host: example.com
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.114 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Accept-Encoding: gzip, deflate
Connection: close`
}
export const GenerateSwitchChannelId = () => {
return uuidv4();
}
export default function SettingsSwitchRoute() {
const [send] = useJsonRpc();
const [kvmSwitchEnabled, setKvmSwitchEnabled] = useState<boolean | null>(null);
const [switchChannels, setSwitchChannels] = useState<SwitchChannel[]>([]);
const updateSwitchChannelData = (index: number, data: SwitchChannel) => {
const newSwitchChannels = [...switchChannels];
newSwitchChannels[index] = data;
setSwitchChannels(newSwitchChannels);
};
const updateSwitchChannelCommands = (index: number, channelIndex: number, data: SwitchChannelCommands) => {
const newSwitchChannels = [...switchChannels];
newSwitchChannels[index].commands[channelIndex] = data;
setSwitchChannels(newSwitchChannels);
};
const removeChannelById = (index: number) => {
const newSwitchChannels = [...switchChannels];
newSwitchChannels.splice(index, 1);
setSwitchChannels(newSwitchChannels);
};
const removeCommandById = (index: number, channelIndex: number) => {
const newSwitchChannels = [...switchChannels];
newSwitchChannels[index].commands.splice(channelIndex, 1);
setSwitchChannels(newSwitchChannels);
};
const addChannel = (afterIndex: number) => {
const newSwitchChannels = [...switchChannels];
newSwitchChannels.splice(afterIndex + 1, 0, {
name: "",
id: GenerateSwitchChannelId(),
commands: [
{
address: "",
protocol: "tcp",
format: "hex",
commands: "",
},
],
});
setSwitchChannels(newSwitchChannels);
};
const addCommand = (channelIndex: number, afterIndex: number) => {
const newSwitchChannels = [...switchChannels];
newSwitchChannels[channelIndex].commands.splice(afterIndex + 1, 0, {
address: "",
protocol: "tcp",
format: "hex",
commands: "",
});
setSwitchChannels(newSwitchChannels);
}
const moveChannel = (fromIndex: number, moveUp: boolean) => {
const newSwitchChannels = [...switchChannels];
const channel = newSwitchChannels[fromIndex];
newSwitchChannels.splice(fromIndex, 1);
newSwitchChannels.splice(fromIndex + (moveUp ? -1 : 1), 0, channel);
setSwitchChannels(newSwitchChannels);
}
const moveCommand = (channelIndex: number, fromIndex: number, moveUp: boolean) => {
const newSwitchChannels = [...switchChannels];
const command = newSwitchChannels[channelIndex].commands[fromIndex];
newSwitchChannels[channelIndex].commands.splice(fromIndex, 1);
newSwitchChannels[channelIndex].commands.splice(fromIndex + (moveUp ? -1 : 1), 0, command);
setSwitchChannels(newSwitchChannels);
}
useEffect(() => {
send("getKvmSwitchEnabled", {}, resp => {
if ("error" in resp) {
notifications.error(`Failed to get KVM switch state: ${resp.error.data || "Unknown error"}`);
return
}
const enabled = Boolean(resp.result);
setKvmSwitchEnabled(enabled);
if (enabled) {
send("getKvmSwitchChannels", {}, resp => {
if ("error" in resp) {
notifications.error(`Failed to get switch channels: ${resp.error.data || "Unknown error"}`);
return
}
setSwitchChannels(resp.result as SwitchChannel[]);
})
} else {
setSwitchChannels([]);
}
});
}, [send]);
const setKvmSwitchEnabledHandler = (enabled: boolean) => {
send("setKvmSwitchEnabled", { enabled: Boolean(enabled) }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to set KVM switch state: ${resp.error.data || "Unknown error"}`,
);
return;
}
if (enabled) {
notifications.success(`Enabled KVM Switch integration`);
} else {
notifications.success(`Disabled KVM Switch integration`);
}
setKvmSwitchEnabled(enabled);
});
};
const getErrorsForChannelCommand = useCallback((index: number, channelIndex: number) => {
const channel = switchChannels[index];
const command = channel.commands[channelIndex];
let errors: string[] = [];
// Check name
const nameValue = channel.name.trim();
if (nameValue.length === 0) {
errors.push("Name cannot be empty");
}
// Check address
if (command.protocol !== "http" && command.protocol !== "https") {
const addressValue = command.address.trim();
// Check that it is in form host:port
const addressParts = addressValue.split(":");
if (addressValue.length === 0) {
errors.push("Address cannot be empty");
} else if (addressParts.length !== 2) {
errors.push("Address must be in form host:port");
} else if (addressParts[0].length === 0) {
errors.push("Address host cannot be empty");
} else if (addressParts[1].length === 0) {
errors.push("Address port cannot be empty");
} else if (!/^\d+$/.test(addressParts[1])) {
errors.push("Address port must be a number");
}
}
// Check that commands map to the format
if (command.format === "hex") {
const messages = command.commands.split(",").map(x => x.trim());
if (messages.length === 0) {
errors.push("Commands cannot be empty");
} else if (messages.some(x => !/^\s*0x[0-9a-fA-F]+\s*$/.test(x))) {
errors.push("Commands must be in hex format (0x123020392193)");
}
}
if (command.format === "base64") {
const messages = command.commands.split(",").map(x => x.trim());
if (messages.length === 0) {
errors.push("Commands cannot be empty");
}
let anyBase64Failed = false;
if ("atob" in window) {
for (const message of messages) {
try {
atob(message);
} catch (e) {
anyBase64Failed = true;
}
}
}
if (anyBase64Failed) {
errors.push("Commands must be in base64 format");
}
}
if (command.format === "http-raw") {
// Check that main components of HTTP request are in the command field
const commandValue = command.commands.trim();
if (commandValue.length === 0) {
errors.push("HTTP request cannot be empty");
} else {
// Check that Host header is present
if (!commandValue.includes("Host:")) {
errors.push("HTTP request must contain Host header");
}
}
}
return errors;
}, [switchChannels]);
useEffect(() => {
if (!kvmSwitchEnabled) {
return
}
// Iterate over getErrorsForChannelCommand
var anyErrorsFound = false;
for (const index in switchChannels) {
if (anyErrorsFound) {
break;
}
for (const channelIndex in switchChannels[index].commands) {
const errors = getErrorsForChannelCommand(Number(index), Number(channelIndex));
if (errors.length > 0) {
anyErrorsFound = true;
break;
}
}
}
if (anyErrorsFound) {
return;
}
const payload = {
config: {
channels: switchChannels
}
};
send("setKvmSwitchChannels", payload, resp => {
if ("error" in resp) {
notifications.error(
`Failed to set switch channels: ${resp.error.data || "Unknown error"}`,
);
return;
}
});
}, [kvmSwitchEnabled, switchChannels, getErrorsForChannelCommand]);
return (
<div className="space-y-3">
<div className="space-y-4">
<SettingsPageHeader
title="KVM Switch"
description="Configure remote KVM switch"
/>
<div className="space-y-4">
<div className="space-y-4">
<SettingsItem
title="Enable remote KVM Switch"
description="Enable ability to switch devices using external KVM Switch"
>
<Checkbox
checked={kvmSwitchEnabled ?? false}
disabled={kvmSwitchEnabled === null}
onChange={e => setKvmSwitchEnabledHandler(e.target.checked)}
/>
</SettingsItem>
{!!kvmSwitchEnabled && switchChannels.length > 0 && (
<div className="space-y-4 flex flex-col">
{switchChannels.map((option, index) => (
<GridCard key={index}>
<div className="space-y-4 p-4 py-3">
<div className="flex items-center justify-between">
<FieldLabel label={"Channel #" + (index + 1)} />
<div className="flex items-center space-x-1">
{switchChannels.length > 1 && (
<Fragment>
<Button
size="XS"
theme="light"
text=""
LeadingIcon={MdOutlineNorth}
onClick={() => {
moveChannel(index, true);
}}
/>
<Button
size="XS"
theme="light"
text=""
LeadingIcon={MdOutlineSouth}
onClick={() => {
moveChannel(index, false);
}}
/>
</Fragment>
)}
<Button
size="XS"
theme="light"
text=""
LeadingIcon={MdOutlinePlusOne}
onClick={() => {
addChannel(index);
}}
/>
<Button
size="XS"
theme="danger"
text=""
LeadingIcon={MdOutlineDelete}
onClick={() => {
// Remove channel by index
removeChannelById(index);
}}
/>
</div>
</div>
<InputFieldWithLabel
label="Channel Name"
type="text"
placeholder="Channel name for UI"
value={option.name}
onChange={e => updateSwitchChannelData(index, { ...option, name: e.target.value })}
/>
{option.commands.map((command, commandIndex) => (
<div key={commandIndex} className="space-y-4">
<div className="flex items-center space-x-2">
<div className="flex-1">
<FieldLabel label={"Command #" + (commandIndex + 1)} />
</div>
<div className="flex items-center space-x-1">
{option.commands.length > 1 && (
<Fragment>
<Button
size="XS"
theme="light"
text=""
LeadingIcon={MdOutlineNorth}
onClick={() => {
moveCommand(index, commandIndex, true);
}}
/>
<Button
size="XS"
theme="light"
text=""
LeadingIcon={MdOutlineSouth}
onClick={() => {
moveCommand(index, commandIndex, false);
}}
/>
</Fragment>
)}
<Button
size="XS"
theme="light"
text=""
LeadingIcon={MdOutlinePlusOne}
onClick={() => {
addCommand(index, commandIndex);
}}
/>
{option.commands.length > 1 && (
<Button
size="XS"
theme="danger"
text=""
LeadingIcon={MdOutlineDelete}
onClick={() => {
removeCommandById(index, commandIndex);
}}
/>
)}
</div>
</div>
<div className="flex items-center space-x-2">
<div className="w-full space-y-1">
<FieldLabel label="Address" id={`address-${index}-${commandIndex}`} as="span" />
<InputField
id={`address-${index}-${commandIndex}`}
type="text"
placeholder={command.protocol == "http" || command.protocol == "https" ? "DISABLED FOR HTTP(s)" : "127.0.0.1"}
disabled={command.protocol == "http" || command.protocol == "https"}
value={command.protocol == "http" || command.protocol == "https" ? "" : command.address}
onChange={e => updateSwitchChannelCommands(index, commandIndex, { ...command, address: e.target.value })}
/>
</div>
<SelectMenuBasic
label="Protocol"
value={command.protocol}
fullWidth
onChange={e => updateSwitchChannelCommands(
index,
commandIndex,
{
...command,
protocol: e.target.value as SwitchChannelCommands["protocol"],
format: GetCompatibleCommands(e.target.value as SwitchChannelCommands["protocol"])[0] as SwitchChannelCommands["format"],
commands: ""
})}
options={[
{ label: "TCP", value: "tcp" },
{ label: "UDP", value: "udp" },
{ label: "HTTP", value: "http" },
{ label: "HTTPS", value: "https" },
]}
/>
<SelectMenuBasic
label="Format"
value={command.format}
fullWidth
onChange={e => updateSwitchChannelCommands(
index,
commandIndex,
{
...command,
format: e.target.value as SwitchChannelCommands["format"], commands: ""
})}
options={[
{ label: "HEX", value: "hex" },
{ label: "Base64", value: "base64" },
{ label: "ASCII", value: "ascii" },
{ label: "HTTP Raw", value: "http-raw" },
].filter(f => GetCompatibleCommands(command.protocol).includes(f.value))}
/>
</div>
<div className="w-full space-y-1">
<FieldLabel label="Command" id={`command-${index}-${commandIndex}`} as="span" description={CommandsTextHelp[command.format]} />
<TextArea
id={`command-${index}-${commandIndex}`}
placeholder={CommandsTextPlaceholder[command.format]}
value={command.commands}
onChange={e => updateSwitchChannelCommands(index, commandIndex, { ...command, commands: e.target.value })}
/>
</div>
{getErrorsForChannelCommand(index, commandIndex).length > 0 && (
<div className="w-full space-y-1">
<FieldError error={getErrorsForChannelCommand(index, commandIndex).join(", ")} />
</div>
)}
{(commandIndex < option.commands.length - 1) && (<div className="h-px bg-gray-200 dark:bg-gray-700" />)}
</div>
))}
</div>
</GridCard>
))}
</div>
)}
{!!kvmSwitchEnabled && switchChannels.length == 0 && (
<div className="max-w-3xl">
<Button
size="LG"
theme="primary"
text="Add Channel"
onClick={() => {
addChannel(0);
}}
/>
</div>
)}
</div>
</div>
</div>
</div>
);
}

View File

@ -8,6 +8,7 @@ import {
LuWrench,
LuArrowLeft,
LuPalette,
LuMerge,
} from "react-icons/lu";
import React, { useEffect, useRef, useState } from "react";
@ -195,6 +196,17 @@ export default function SettingsRoute() {
</div>
</NavLink>
</div>
<div className="shrink-0">
<NavLink
to="switch"
className={({ isActive }) => (isActive ? "active" : "")}
>
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 [.active_&]:bg-blue-50 [.active_&]:!text-blue-700 md:[.active_&]:bg-transparent dark:[.active_&]:bg-blue-900 dark:[.active_&]:!text-blue-200 dark:md:[.active_&]:bg-transparent">
<LuMerge className="h-4 w-4 shrink-0" />
<h1>KVM Switch</h1>
</div>
</NavLink>
</div>
<div className="shrink-0">
<NavLink
to="advanced"