diff --git a/config.go b/config.go index 642f113..4f2b862 100644 --- a/config.go +++ b/config.go @@ -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" diff --git a/jsonrpc.go b/jsonrpc.go index 9ce1f1b..d44cbc4 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -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"}}, } diff --git a/remote_kvm.go b/remote_kvm.go new file mode 100644 index 0000000..7d2ec57 --- /dev/null +++ b/remote_kvm.go @@ -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 +} diff --git a/ui/package-lock.json b/ui/package-lock.json index ebce148..078541c 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -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", diff --git a/ui/package.json b/ui/package.json index a248616..833cdcc 100644 --- a/ui/package.json +++ b/ui/package.json @@ -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" diff --git a/ui/src/components/ActionBar.tsx b/ui/src/components/ActionBar.tsx index 1afef63..ac22e63 100644 --- a/ui/src/components/ActionBar.tsx +++ b/ui/src/components/ActionBar.tsx @@ -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; }) { + 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 (
setVirtualKeyboard(!virtualKeyboard)} />
+ {remoteKvmEnabled && ( +
+ + +
+ )} +
diff --git a/ui/src/components/popovers/SelectChannelPopover.tsx b/ui/src/components/popovers/SelectChannelPopover.tsx new file mode 100644 index 0000000..dfaef62 --- /dev/null +++ b/ui/src/components/popovers/SelectChannelPopover.tsx @@ -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([]); + 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 ( + +
+
+
+
+ + +
+
+ onChannelSelectedById(e.target.value)} + options={options} + fullWidth + /> +
+
+
+
+
+
+
+ ); +} diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index f30c28c..ab9e51b 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -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(set => ({ @@ -78,6 +91,12 @@ export const useUiStore = create(set => ({ isAttachedVirtualKeyboardVisible: true, setAttachedVirtualKeyboardVisibility: enabled => set({ isAttachedVirtualKeyboardVisible: enabled }), + + remoteKvmEnabled: false, + setRemoteKvmEnabled: enabled => set({ remoteKvmEnabled: enabled }), + + remoteKvmSelectedChannel: null, + setRemoteKvmSelectedChannel: channel => set({ remoteKvmSelectedChannel: channel }), })); interface RTCState { diff --git a/ui/src/main.tsx b/ui/src/main.tsx index 066ee57..337ceb7 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -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: , }, + { + path: "switch", + element: , + }, { path: "advanced", element: , @@ -253,6 +258,10 @@ if (isOnDevice) { path: "mouse", element: , }, + { + path: "switch", + element: , + }, { path: "advanced", element: , diff --git a/ui/src/routes/devices.$id.settings.switch.tsx b/ui/src/routes/devices.$id.settings.switch.tsx new file mode 100644 index 0000000..abbbb0f --- /dev/null +++ b/ui/src/routes/devices.$id.settings.switch.tsx @@ -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 = { + 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 = { + 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(null); + const [switchChannels, setSwitchChannels] = useState([]); + + 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 ( +
+
+ + +
+
+ + setKvmSwitchEnabledHandler(e.target.checked)} + /> + + + {!!kvmSwitchEnabled && switchChannels.length > 0 && ( +
+ {switchChannels.map((option, index) => ( + +
+
+ +
+ {switchChannels.length > 1 && ( + +
+
+ updateSwitchChannelData(index, { ...option, name: e.target.value })} + /> + {option.commands.map((command, commandIndex) => ( +
+
+
+ +
+
+ {option.commands.length > 1 && ( + +
+ +
+
+
+ + updateSwitchChannelCommands(index, commandIndex, { ...command, address: e.target.value })} + /> +
+ 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" }, + ]} + /> + 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))} + /> +
+
+ +