diff --git a/Makefile b/Makefile index 0453301..2aefdea 100644 --- a/Makefile +++ b/Makefile @@ -1,21 +1,26 @@ BRANCH ?= $(shell git rev-parse --abbrev-ref HEAD) BUILDDATE ?= $(shell date -u +%FT%T%z) +BUILDTS ?= $(shell date -u +%s) REVISION ?= $(shell git rev-parse HEAD) -VERSION_DEV := 0.3.8-dev$(shell date +%Y%m%d%H%M) -VERSION := 0.3.7 +VERSION_DEV := 0.3.9-dev$(shell date +%Y%m%d%H%M) +VERSION := 0.3.8 + +PROMETHEUS_TAG := github.com/prometheus/common/version +KVM_PKG_NAME := github.com/jetkvm/kvm GO_LDFLAGS := \ -s -w \ - -X github.com/prometheus/common/version.Branch=$(BRANCH) \ - -X github.com/prometheus/common/version.BuildDate=$(BUILDDATE) \ - -X github.com/prometheus/common/version.Revision=$(REVISION) + -X $(PROMETHEUS_TAG).Branch=$(BRANCH) \ + -X $(PROMETHEUS_TAG).BuildDate=$(BUILDDATE) \ + -X $(PROMETHEUS_TAG).Revision=$(REVISION) \ + -X $(KVM_PKG_NAME).builtTimestamp=$(BUILDTS) hash_resource: @shasum -a 256 resource/jetkvm_native | cut -d ' ' -f 1 > resource/jetkvm_native.sha256 build_dev: hash_resource @echo "Building..." - GOOS=linux GOARCH=arm GOARM=7 go build -ldflags="$(GO_LDFLAGS) -X kvm.builtAppVersion=$(VERSION_DEV)" -o bin/jetkvm_app cmd/main.go + GOOS=linux GOARCH=arm GOARM=7 go build -ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION_DEV)" -o bin/jetkvm_app cmd/main.go frontend: cd ui && npm ci && npm run build:device @@ -28,7 +33,7 @@ dev_release: frontend build_dev build_release: frontend hash_resource @echo "Building release..." - GOOS=linux GOARCH=arm GOARM=7 go build -ldflags="$(GO_LDFLAGS) -X kvm.builtAppVersion=$(VERSION)" -o bin/jetkvm_app cmd/main.go + GOOS=linux GOARCH=arm GOARM=7 go build -ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION)" -o bin/jetkvm_app cmd/main.go release: @if rclone lsf r2://jetkvm-update/app/$(VERSION)/ | grep -q "jetkvm_app"; then \ diff --git a/internal/usbgadget/config.go b/internal/usbgadget/config.go index 5f08733..5cc3ed2 100644 --- a/internal/usbgadget/config.go +++ b/internal/usbgadget/config.go @@ -128,6 +128,15 @@ func (u *UsbGadget) GetConfigPath(itemKey string) (string, error) { return joinPath(u.kvmGadgetPath, item.configPath), nil } +// GetPath returns the path to the item. +func (u *UsbGadget) GetPath(itemKey string) (string, error) { + item, ok := u.configMap[itemKey] + if !ok { + return "", fmt.Errorf("config item %s not found", itemKey) + } + return joinPath(u.kvmGadgetPath, item.path), nil +} + func mountConfigFS() error { _, err := os.Stat(gadgetPath) // TODO: check if it's mounted properly diff --git a/jsonrpc.go b/jsonrpc.go index 298a810..64935e1 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -799,6 +799,7 @@ var rpcHandlers = map[string]RPCHandler{ "getCloudState": {Func: rpcGetCloudState}, "keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}}, "absMouseReport": {Func: rpcAbsMouseReport, Params: []string{"x", "y", "buttons"}}, + "relMouseReport": {Func: rpcRelMouseReport, Params: []string{"dx", "dy", "buttons"}}, "wheelReport": {Func: rpcWheelReport, Params: []string{"wheelY"}}, "getVideoState": {Func: rpcGetVideoState}, "getUSBState": {Func: rpcGetUSBState}, diff --git a/main.go b/main.go index 0673389..6a55595 100644 --- a/main.go +++ b/main.go @@ -35,6 +35,8 @@ func Main() { StartNativeCtrlSocketServer() StartNativeVideoSocketServer() + initPrometheus() + go func() { err = ExtractAndRunNativeBin() if err != nil { @@ -67,6 +69,9 @@ func Main() { }() //go RunFuseServer() go RunWebServer() + if config.TLSMode != "" { + go RunWebSecureServer() + } // If the cloud token isn't set, the client won't be started by default. // However, if the user adopts the device via the web interface, handleCloudRegister will start the client. if config.CloudToken != "" { diff --git a/prometheus.go b/prometheus.go new file mode 100644 index 0000000..8ebf259 --- /dev/null +++ b/prometheus.go @@ -0,0 +1,17 @@ +package kvm + +import ( + "net/http" + + "github.com/prometheus/client_golang/prometheus" + versioncollector "github.com/prometheus/client_golang/prometheus/collectors/version" + "github.com/prometheus/common/version" +) + +var promHandler http.Handler + +func initPrometheus() { + // A Prometheus metrics endpoint. + version.Version = builtAppVersion + prometheus.MustRegister(versioncollector.NewCollector("jetkvm")) +} diff --git a/ui/src/components/InfoBar.tsx b/ui/src/components/InfoBar.tsx index be94043..7c002f1 100644 --- a/ui/src/components/InfoBar.tsx +++ b/ui/src/components/InfoBar.tsx @@ -14,6 +14,7 @@ export default function InfoBar() { const activeModifiers = useHidStore(state => state.activeModifiers); const mouseX = useMouseStore(state => state.mouseX); const mouseY = useMouseStore(state => state.mouseY); + const mouseMove = useMouseStore(state => state.mouseMove); const videoClientSize = useVideoStore( state => `${Math.round(state.clientWidth)}x${Math.round(state.clientHeight)}`, @@ -62,7 +63,7 @@ export default function InfoBar() { </div> ) : null} - {settings.debugMode ? ( + {(settings.debugMode && settings.mouseMode == "absolute") ? ( <div className="flex w-[118px] items-center gap-x-1"> <span className="text-xs font-semibold">Pointer:</span> <span className="text-xs"> @@ -71,6 +72,17 @@ export default function InfoBar() { </div> ) : null} + {(settings.debugMode && settings.mouseMode == "relative") ? ( + <div className="flex w-[118px] items-center gap-x-1"> + <span className="text-xs font-semibold">Last Move:</span> + <span className="text-xs"> + {mouseMove ? + `${mouseMove.x},${mouseMove.y} ${mouseMove.buttons ? `(${mouseMove.buttons})` : ""}` : + "N/A"} + </span> + </div> + ) : null} + {settings.debugMode && ( <div className="flex w-[156px] items-center gap-x-1"> <span className="text-xs font-semibold">USB State:</span> diff --git a/ui/src/components/USBConfigDialog.tsx b/ui/src/components/USBConfigDialog.tsx deleted file mode 100644 index db8b677..0000000 --- a/ui/src/components/USBConfigDialog.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { Button } from "@components/Button"; -import { InputFieldWithLabel } from "./InputField"; -import { UsbConfigState } from "@/hooks/stores"; -import { useEffect, useCallback, useState } from "react"; -import { useJsonRpc } from "../hooks/useJsonRpc"; -import { USBConfig } from "./UsbConfigSetting"; - -export default function UpdateUsbConfigModal({ - onSetUsbConfig, - onRestoreToDefault, -}: { - onSetUsbConfig: (usbConfig: USBConfig) => void; - onRestoreToDefault: () => void; -}) { - const [usbConfigState, setUsbConfigState] = useState<USBConfig>({ - vendor_id: "", - product_id: "", - serial_number: "", - manufacturer: "", - product: "", - }); - - const [send] = useJsonRpc(); - - const syncUsbConfig = useCallback(() => { - send("getUsbConfig", {}, resp => { - if ("error" in resp) { - console.error("Failed to load USB Config:", resp.error); - } else { - setUsbConfigState(resp.result as UsbConfigState); - } - }); - }, [send, setUsbConfigState]); - - // Load stored usb config from the backend - useEffect(() => { - syncUsbConfig(); - }, [syncUsbConfig]); - - const handleUsbVendorIdChange = (value: string) => { - setUsbConfigState({ ...usbConfigState, vendor_id: value }); - }; - - const handleUsbProductIdChange = (value: string) => { - setUsbConfigState({ ...usbConfigState, product_id: value }); - }; - - const handleUsbSerialChange = (value: string) => { - setUsbConfigState({ ...usbConfigState, serial_number: value }); - }; - - const handleUsbManufacturer = (value: string) => { - setUsbConfigState({ ...usbConfigState, manufacturer: value }); - }; - - const handleUsbProduct = (value: string) => { - setUsbConfigState({ ...usbConfigState, product: value }); - }; - - return ( - <div className="space-y-6"> - <div className="grid grid-cols-2 gap-4"> - <InputFieldWithLabel - required - label="Vendor ID" - placeholder="Enter Vendor ID" - pattern="^0[xX][\da-fA-F]{4}$" - defaultValue={usbConfigState?.vendor_id} - onChange={e => handleUsbVendorIdChange(e.target.value)} - /> - <InputFieldWithLabel - required - label="Product ID" - placeholder="Enter Product ID" - pattern="^0[xX][\da-fA-F]{4}$" - defaultValue={usbConfigState?.product_id} - onChange={e => handleUsbProductIdChange(e.target.value)} - /> - <InputFieldWithLabel - required - label="Serial Number" - placeholder="Enter Serial Number" - defaultValue={usbConfigState?.serial_number} - onChange={e => handleUsbSerialChange(e.target.value)} - /> - <InputFieldWithLabel - required - label="Manufacturer" - placeholder="Enter Manufacturer" - defaultValue={usbConfigState?.manufacturer} - onChange={e => handleUsbManufacturer(e.target.value)} - /> - <InputFieldWithLabel - required - label="Product Name" - placeholder="Enter Product Name" - defaultValue={usbConfigState?.product} - onChange={e => handleUsbProduct(e.target.value)} - /> - </div> - <div className="flex gap-x-2"> - <Button - size="SM" - theme="primary" - text="Update USB Config" - onClick={() => onSetUsbConfig(usbConfigState)} - /> - <Button - size="SM" - theme="light" - text="Restore to Default" - onClick={onRestoreToDefault} - /> - </div> - </div> - ); -} diff --git a/ui/src/components/UsbDeviceSetting.tsx b/ui/src/components/UsbDeviceSetting.tsx index 1c8812c..07125e6 100644 --- a/ui/src/components/UsbDeviceSetting.tsx +++ b/ui/src/components/UsbDeviceSetting.tsx @@ -5,7 +5,10 @@ import { useJsonRpc } from "../hooks/useJsonRpc"; import notifications from "../notifications"; import { SettingsItem } from "../routes/devices.$id.settings"; import Checkbox from "./Checkbox"; - +import { Button } from "./Button"; +import { SelectMenuBasic } from "./SelectMenuBasic"; +import { SettingsSectionHeader } from "./SettingsSectionHeader"; +import Fieldset from "./Fieldset"; export interface USBConfig { vendor_id: string; product_id: string; @@ -26,12 +29,43 @@ const defaultUsbDeviceConfig: UsbDeviceConfig = { absolute_mouse: true, relative_mouse: true, mass_storage: true, -} +}; + +const usbPresets = [ + { + label: "Keyboard, Mouse and Mass Storage", + value: "default", + config: { + keyboard: true, + absolute_mouse: true, + relative_mouse: true, + mass_storage: true, + }, + }, + { + label: "Keyboard Only", + value: "keyboard_only", + config: { + keyboard: true, + absolute_mouse: false, + relative_mouse: false, + mass_storage: false, + }, + }, + { + label: "Custom", + value: "custom", + }, +]; export function UsbDeviceSetting() { const [send] = useJsonRpc(); + const [loading, setLoading] = useState(false); + + const [usbDeviceConfig, setUsbDeviceConfig] = + useState<UsbDeviceConfig>(defaultUsbDeviceConfig); + const [selectedPreset, setSelectedPreset] = useState<string>("default"); - const [usbDeviceConfig, setUsbDeviceConfig] = useState<UsbDeviceConfig>(defaultUsbDeviceConfig); const syncUsbDeviceConfig = useCallback(() => { send("getUsbDevices", {}, resp => { if ("error" in resp) { @@ -40,90 +74,167 @@ export function UsbDeviceSetting() { `Failed to load USB devices: ${resp.error.data || "Unknown error"}`, ); } else { - console.log("syncUsbDeviceConfig#getUsbDevices result:", resp.result); const usbConfigState = resp.result as UsbDeviceConfig; setUsbDeviceConfig(usbConfigState); + + // Set the appropriate preset based on current config + const matchingPreset = usbPresets.find( + preset => + preset.value !== "custom" && + preset.config && + Object.keys(preset.config).length === Object.keys(usbConfigState).length && + Object.keys(preset.config).every(key => { + const configKey = key as keyof typeof preset.config; + return preset.config[configKey] === usbConfigState[configKey]; + }), + ); + + setSelectedPreset(matchingPreset ? matchingPreset.value : "custom"); } }); }, [send]); const handleUsbConfigChange = useCallback( (devices: UsbDeviceConfig) => { - send("setUsbDevices", { devices }, resp => { + setLoading(true); + send("setUsbDevices", { devices }, async resp => { if ("error" in resp) { notifications.error( `Failed to set usb devices: ${resp.error.data || "Unknown error"}`, ); + setLoading(false); return; } - notifications.success( - `USB Devices updated` - ); + + // We need some time to ensure the USB devices are updated + await new Promise(resolve => setTimeout(resolve, 2000)); + setLoading(false); syncUsbDeviceConfig(); + notifications.success(`USB Devices updated`); }); }, [send, syncUsbDeviceConfig], ); - const onUsbConfigItemChange = useCallback((key: keyof UsbDeviceConfig) => (e: React.ChangeEvent<HTMLInputElement>) => { - setUsbDeviceConfig((val) => { - val[key] = e.target.checked; - handleUsbConfigChange(val); - return val; - }); - }, [handleUsbConfigChange]); + const onUsbConfigItemChange = useCallback( + (key: keyof UsbDeviceConfig) => (e: React.ChangeEvent<HTMLInputElement>) => { + setUsbDeviceConfig(prev => ({ + ...prev, + [key]: e.target.checked, + })); + }, + [], + ); + + const handlePresetChange = useCallback( + async (e: React.ChangeEvent<HTMLSelectElement>) => { + const newPreset = e.target.value; + setSelectedPreset(newPreset); + + if (newPreset !== "custom") { + const presetConfig = usbPresets.find( + preset => preset.value === newPreset, + )?.config; + + if (presetConfig) { + handleUsbConfigChange(presetConfig); + } + } + }, + [handleUsbConfigChange], + ); useEffect(() => { syncUsbDeviceConfig(); }, [syncUsbDeviceConfig]); return ( - <> + <Fieldset disabled={loading} className="space-y-4"> <div className="h-[1px] w-full bg-slate-800/10 dark:bg-slate-300/20" /> - <div className="space-y-4"> - <SettingsItem - title="Enable Keyboard" - description="Enable Keyboard" - > - <Checkbox - checked={usbDeviceConfig.keyboard} - onChange={onUsbConfigItemChange("keyboard")} - /> - </SettingsItem> - </div> - <div className="space-y-4"> - <SettingsItem - title="Enable Absolute Mouse (Pointer)" - description="Enable Absolute Mouse (Pointer)" - > - <Checkbox - checked={usbDeviceConfig.absolute_mouse} - onChange={onUsbConfigItemChange("absolute_mouse")} - /> - </SettingsItem> - </div> - <div className="space-y-4"> - <SettingsItem - title="Enable Relative Mouse" - description="Enable Relative Mouse" - > - <Checkbox - checked={usbDeviceConfig.relative_mouse} - onChange={onUsbConfigItemChange("relative_mouse")} - /> - </SettingsItem> - </div> - <div className="space-y-4"> - <SettingsItem - title="Enable USB Mass Storage" - description="Sometimes it might need to be disabled to prevent issues with certain devices" - > - <Checkbox - checked={usbDeviceConfig.mass_storage} - onChange={onUsbConfigItemChange("mass_storage")} - /> - </SettingsItem> - </div> - </> + + <SettingsSectionHeader + title="USB Device" + description="USB devices to emulate on the target computer" + /> + + <SettingsItem + loading={loading} + title="Classes" + description="USB device classes in the composite device" + > + <SelectMenuBasic + size="SM" + label="" + className="max-w-[292px]" + value={selectedPreset} + fullWidth + onChange={handlePresetChange} + options={usbPresets} + /> + </SettingsItem> + + {selectedPreset === "custom" && ( + <div className="ml-2 border-l border-slate-800/10 pl-4 dark:border-slate-300/20 "> + <div className="space-y-4"> + <div className="space-y-4"> + <SettingsItem title="Enable Keyboard" description="Enable Keyboard"> + <Checkbox + checked={usbDeviceConfig.keyboard} + onChange={onUsbConfigItemChange("keyboard")} + /> + </SettingsItem> + </div> + <div className="space-y-4"> + <SettingsItem + title="Enable Absolute Mouse (Pointer)" + description="Enable Absolute Mouse (Pointer)" + > + <Checkbox + checked={usbDeviceConfig.absolute_mouse} + onChange={onUsbConfigItemChange("absolute_mouse")} + /> + </SettingsItem> + </div> + <div className="space-y-4"> + <SettingsItem + title="Enable Relative Mouse" + description="Enable Relative Mouse" + > + <Checkbox + checked={usbDeviceConfig.relative_mouse} + onChange={onUsbConfigItemChange("relative_mouse")} + /> + </SettingsItem> + </div> + <div className="space-y-4"> + <SettingsItem + title="Enable USB Mass Storage" + description="Sometimes it might need to be disabled to prevent issues with certain devices" + > + <Checkbox + checked={usbDeviceConfig.mass_storage} + onChange={onUsbConfigItemChange("mass_storage")} + /> + </SettingsItem> + </div> + </div> + <div className="mt-6 flex gap-x-2"> + <Button + size="SM" + loading={loading} + theme="primary" + text="Update USB Classes" + onClick={() => handleUsbConfigChange(usbDeviceConfig)} + /> + <Button + size="SM" + theme="light" + text="Restore to Default" + onClick={() => handleUsbConfigChange(defaultUsbDeviceConfig)} + /> + </div> + </div> + )} + </Fieldset> ); } diff --git a/ui/src/components/UsbConfigSetting.tsx b/ui/src/components/UsbInfoSetting.tsx similarity index 51% rename from ui/src/components/UsbConfigSetting.tsx rename to ui/src/components/UsbInfoSetting.tsx index 1e8ab03..4ac93ff 100644 --- a/ui/src/components/UsbConfigSetting.tsx +++ b/ui/src/components/UsbInfoSetting.tsx @@ -1,6 +1,8 @@ import { useMemo } from "react"; import { useCallback } from "react"; +import { Button } from "@components/Button"; +import { InputFieldWithLabel } from "./InputField"; import { useEffect, useState } from "react"; import { UsbConfigState } from "../hooks/stores"; @@ -8,7 +10,7 @@ import { useJsonRpc } from "../hooks/useJsonRpc"; import notifications from "../notifications"; import { SettingsItem } from "../routes/devices.$id.settings"; import { SelectMenuBasic } from "./SelectMenuBasic"; -import USBConfigDialog from "./USBConfigDialog"; +import Fieldset from "./Fieldset"; const generatedSerialNumber = [generateNumber(1, 9), generateHex(7, 7), 0, 1].join("&"); @@ -51,8 +53,9 @@ const usbConfigs = [ type UsbConfigMap = Record<string, USBConfig>; -export function UsbConfigSetting() { +export function UsbInfoSetting() { const [send] = useJsonRpc(); + const [loading, setLoading] = useState(false); const [usbConfigProduct, setUsbConfigProduct] = useState(""); const [deviceId, setDeviceId] = useState(""); @@ -110,17 +113,23 @@ export function UsbConfigSetting() { const handleUsbConfigChange = useCallback( (usbConfig: USBConfig) => { - send("setUsbConfig", { usbConfig }, resp => { + setLoading(true); + send("setUsbConfig", { usbConfig }, async resp => { if ("error" in resp) { notifications.error( `Failed to set usb config: ${resp.error.data || "Unknown error"}`, ); + setLoading(false); return; } - // setUsbConfigProduct(usbConfig.product); + + // We need some time to ensure the USB devices are updated + await new Promise(resolve => setTimeout(resolve, 2000)); + setLoading(false); notifications.success( `USB Config set to ${usbConfig.manufacturer} ${usbConfig.product}`, ); + syncUsbConfigProduct(); }); }, @@ -141,18 +150,18 @@ export function UsbConfigSetting() { }, [send, syncUsbConfigProduct]); return ( - <> - <div className="h-[1px] w-full bg-slate-800/10 dark:bg-slate-300/20" /> - + <Fieldset disabled={loading} className="space-y-4"> <SettingsItem - title="USB Device Emulation" - description="Set a Preconfigured USB Device" + loading={loading} + title="Identifiers" + description="USB device identifiers exposed to the target computer" > <SelectMenuBasic size="SM" label="" className="max-w-[192px]" value={usbConfigProduct} + fullWidth onChange={e => { if (e.target.value === "custom") { setUsbConfigProduct(e.target.value); @@ -165,13 +174,130 @@ export function UsbConfigSetting() { /> </SettingsItem> {usbConfigProduct === "custom" && ( - <USBConfigDialog - onSetUsbConfig={usbConfig => handleUsbConfigChange(usbConfig)} - onRestoreToDefault={() => - handleUsbConfigChange(usbConfigData[usbConfigs[0].value]) - } - /> + <div className="ml-2 space-y-4 border-l border-slate-800/10 pl-4 dark:border-slate-300/20 "> + <USBConfigDialog + loading={loading} + onSetUsbConfig={usbConfig => handleUsbConfigChange(usbConfig)} + onRestoreToDefault={() => + handleUsbConfigChange(usbConfigData[usbConfigs[0].value]) + } + /> + </div> )} - </> + </Fieldset> + ); +} + +function USBConfigDialog({ + loading, + onSetUsbConfig, + onRestoreToDefault, +}: { + loading: boolean; + onSetUsbConfig: (usbConfig: USBConfig) => void; + onRestoreToDefault: () => void; +}) { + const [usbConfigState, setUsbConfigState] = useState<USBConfig>({ + vendor_id: "", + product_id: "", + serial_number: "", + manufacturer: "", + product: "", + }); + + const [send] = useJsonRpc(); + + const syncUsbConfig = useCallback(() => { + send("getUsbConfig", {}, resp => { + if ("error" in resp) { + console.error("Failed to load USB Config:", resp.error); + } else { + setUsbConfigState(resp.result as UsbConfigState); + } + }); + }, [send, setUsbConfigState]); + + // Load stored usb config from the backend + useEffect(() => { + syncUsbConfig(); + }, [syncUsbConfig]); + + const handleUsbVendorIdChange = (value: string) => { + setUsbConfigState({ ...usbConfigState, vendor_id: value }); + }; + + const handleUsbProductIdChange = (value: string) => { + setUsbConfigState({ ...usbConfigState, product_id: value }); + }; + + const handleUsbSerialChange = (value: string) => { + setUsbConfigState({ ...usbConfigState, serial_number: value }); + }; + + const handleUsbManufacturer = (value: string) => { + setUsbConfigState({ ...usbConfigState, manufacturer: value }); + }; + + const handleUsbProduct = (value: string) => { + setUsbConfigState({ ...usbConfigState, product: value }); + }; + + return ( + <div className=""> + <div className="grid grid-cols-2 gap-4"> + <InputFieldWithLabel + required + label="Vendor ID" + placeholder="Enter Vendor ID" + pattern="^0[xX][\da-fA-F]{4}$" + defaultValue={usbConfigState?.vendor_id} + onChange={e => handleUsbVendorIdChange(e.target.value)} + /> + <InputFieldWithLabel + required + label="Product ID" + placeholder="Enter Product ID" + pattern="^0[xX][\da-fA-F]{4}$" + defaultValue={usbConfigState?.product_id} + onChange={e => handleUsbProductIdChange(e.target.value)} + /> + <InputFieldWithLabel + required + label="Serial Number" + placeholder="Enter Serial Number" + defaultValue={usbConfigState?.serial_number} + onChange={e => handleUsbSerialChange(e.target.value)} + /> + <InputFieldWithLabel + required + label="Manufacturer" + placeholder="Enter Manufacturer" + defaultValue={usbConfigState?.manufacturer} + onChange={e => handleUsbManufacturer(e.target.value)} + /> + <InputFieldWithLabel + required + label="Product Name" + placeholder="Enter Product Name" + defaultValue={usbConfigState?.product} + onChange={e => handleUsbProduct(e.target.value)} + /> + </div> + <div className="mt-6 flex gap-x-2"> + <Button + loading={loading} + size="SM" + theme="primary" + text="Update USB Identifiers" + onClick={() => onSetUsbConfig(usbConfigState)} + /> + <Button + size="SM" + theme="light" + text="Restore to Default" + onClick={onRestoreToDefault} + /> + </div> + </div> ); } diff --git a/ui/src/components/WebRTCVideo.tsx b/ui/src/components/WebRTCVideo.tsx index 1587d29..de36e37 100644 --- a/ui/src/components/WebRTCVideo.tsx +++ b/ui/src/components/WebRTCVideo.tsx @@ -29,6 +29,7 @@ export default function WebRTCVideo() { const settings = useSettingsStore(); const { sendKeyboardEvent, resetKeyboardState } = useKeyboard(); const setMousePosition = useMouseStore(state => state.setMousePosition); + const setMouseMove = useMouseStore(state => state.setMouseMove); const { setClientSize: setVideoClientSize, setSize: setVideoSize, @@ -93,19 +94,44 @@ export default function WebRTCVideo() { ); // Mouse-related - const sendMouseMovement = useCallback( + const calcDelta = (pos: number) => (Math.abs(pos) < 10 ? pos * 2 : pos); + const sendRelMouseMovement = useCallback( (x: number, y: number, buttons: number) => { - send("absMouseReport", { x, y, buttons }); + if (settings.mouseMode !== "relative") return; + // if we ignore the event, double-click will not work + // if (x === 0 && y === 0 && buttons === 0) return; + send("relMouseReport", { dx: calcDelta(x), dy: calcDelta(y), buttons }); + setMouseMove({ x, y, buttons }); + }, + [send, setMouseMove, settings.mouseMode], + ); + const relMouseMoveHandler = useCallback( + (e: MouseEvent) => { + if (settings.mouseMode !== "relative") return; + + // Send mouse movement + const { buttons } = e; + sendRelMouseMovement(e.movementX, e.movementY, buttons); + }, + [sendRelMouseMovement, settings.mouseMode], + ); + + const sendAbsMouseMovement = useCallback( + (x: number, y: number, buttons: number) => { + if (settings.mouseMode !== "absolute") return; + send("absMouseReport", { x, y, buttons }); // We set that for the debug info bar setMousePosition(x, y); }, - [send, setMousePosition], + [send, setMousePosition, settings.mouseMode], ); - const mouseMoveHandler = useCallback( + const absMouseMoveHandler = useCallback( (e: MouseEvent) => { if (!videoClientWidth || !videoClientHeight) return; + if (settings.mouseMode !== "absolute") return; + // Get the aspect ratios of the video element and the video stream const videoElementAspectRatio = videoClientWidth / videoClientHeight; const videoStreamAspectRatio = videoWidth / videoHeight; @@ -140,9 +166,16 @@ export default function WebRTCVideo() { // Send mouse movement const { buttons } = e; - sendMouseMovement(x, y, buttons); + sendAbsMouseMovement(x, y, buttons); }, - [sendMouseMovement, videoClientHeight, videoClientWidth, videoWidth, videoHeight], + [ + sendAbsMouseMovement, + videoClientHeight, + videoClientWidth, + videoWidth, + videoHeight, + settings.mouseMode, + ], ); const trackpadSensitivity = useDeviceSettingsStore(state => state.trackpadSensitivity); @@ -193,8 +226,8 @@ export default function WebRTCVideo() { ); const resetMousePosition = useCallback(() => { - sendMouseMovement(0, 0, 0); - }, [sendMouseMovement]); + sendAbsMouseMovement(0, 0, 0); + }, [sendAbsMouseMovement]); // Keyboard-related const handleModifierKeys = useCallback( @@ -329,28 +362,6 @@ export default function WebRTCVideo() { ], ); - // Effect hooks - useEffect( - function setupKeyboardEvents() { - const abortController = new AbortController(); - const signal = abortController.signal; - - document.addEventListener("keydown", keyDownHandler, { signal }); - document.addEventListener("keyup", keyUpHandler, { signal }); - - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error - window.clearKeys = () => sendKeyboardEvent([], []); - window.addEventListener("blur", resetKeyboardState, { signal }); - document.addEventListener("visibilitychange", resetKeyboardState, { signal }); - - return () => { - abortController.abort(); - }; - }, - [keyDownHandler, keyUpHandler, resetKeyboardState, sendKeyboardEvent], - ); - const videoKeyUpHandler = useCallback((e: KeyboardEvent) => { // In fullscreen mode in chrome & safari, the space key is used to pause/play the video // there is no way to prevent this, so we need to simply force play the video when it's paused. @@ -363,46 +374,6 @@ export default function WebRTCVideo() { } }, []); - useEffect( - function setupVideoEventListeners() { - let videoElmRefValue = null; - if (!videoElm.current) return; - videoElmRefValue = videoElm.current; - const abortController = new AbortController(); - const signal = abortController.signal; - - videoElmRefValue.addEventListener("mousemove", mouseMoveHandler, { signal }); - videoElmRefValue.addEventListener("pointerdown", mouseMoveHandler, { signal }); - videoElmRefValue.addEventListener("pointerup", mouseMoveHandler, { signal }); - videoElmRefValue.addEventListener("keyup", videoKeyUpHandler, { signal }); - videoElmRefValue.addEventListener("wheel", mouseWheelHandler, { - signal, - passive: true, - }); - videoElmRefValue.addEventListener( - "contextmenu", - (e: MouseEvent) => e.preventDefault(), - { signal }, - ); - videoElmRefValue.addEventListener("playing", onVideoPlaying, { signal }); - - const local = resetMousePosition; - window.addEventListener("blur", local, { signal }); - document.addEventListener("visibilitychange", local, { signal }); - - return () => { - if (videoElmRefValue) abortController.abort(); - }; - }, - [ - mouseMoveHandler, - resetMousePosition, - onVideoPlaying, - mouseWheelHandler, - videoKeyUpHandler, - ], - ); - useEffect( function updateVideoStream() { if (!mediaStream) return; @@ -425,6 +396,120 @@ export default function WebRTCVideo() { ], ); + // Setup Keyboard Events + useEffect( + function setupKeyboardEvents() { + const abortController = new AbortController(); + const signal = abortController.signal; + + document.addEventListener("keydown", keyDownHandler, { signal }); + document.addEventListener("keyup", keyUpHandler, { signal }); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + window.clearKeys = () => sendKeyboardEvent([], []); + window.addEventListener("blur", resetKeyboardState, { signal }); + document.addEventListener("visibilitychange", resetKeyboardState, { signal }); + + return () => { + abortController.abort(); + }; + }, + [keyDownHandler, keyUpHandler, resetKeyboardState, sendKeyboardEvent], + ); + + useEffect( + function setupVideoEventListeners() { + const videoElmRefValue = videoElm.current; + if (!videoElmRefValue) return; + + const abortController = new AbortController(); + const signal = abortController.signal; + + // To prevent the video from being paused when the user presses a space in fullscreen mode + videoElmRefValue.addEventListener("keyup", videoKeyUpHandler, { signal }); + + // We need to know when the video is playing to update state and video size + videoElmRefValue.addEventListener("playing", onVideoPlaying, { signal }); + + return () => { + abortController.abort(); + }; + }, + [ + absMouseMoveHandler, + resetMousePosition, + onVideoPlaying, + mouseWheelHandler, + videoKeyUpHandler, + ], + ); + + // Setup Absolute Mouse Events + useEffect( + function setAbsoluteMouseModeEventListeners() { + const videoElmRefValue = videoElm.current; + if (!videoElmRefValue) return; + + if (settings.mouseMode !== "absolute") return; + + const abortController = new AbortController(); + const signal = abortController.signal; + + videoElmRefValue.addEventListener("mousemove", absMouseMoveHandler, { signal }); + videoElmRefValue.addEventListener("pointerdown", absMouseMoveHandler, { signal }); + videoElmRefValue.addEventListener("pointerup", absMouseMoveHandler, { signal }); + videoElmRefValue.addEventListener("wheel", mouseWheelHandler, { + signal, + passive: true, + }); + + // Reset the mouse position when the window is blurred or the document is hidden + const local = resetMousePosition; + window.addEventListener("blur", local, { signal }); + document.addEventListener("visibilitychange", local, { signal }); + + return () => { + abortController.abort(); + }; + }, + [absMouseMoveHandler, mouseWheelHandler, resetMousePosition, settings.mouseMode], + ); + + // Setup Relative Mouse Events + const containerRef = useRef<HTMLDivElement>(null); + useEffect( + function setupRelativeMouseEventListeners() { + if (settings.mouseMode !== "relative") return; + + const abortController = new AbortController(); + const signal = abortController.signal; + + // We bind to the larger container in relative mode because of delta between the acceleration of the local + // mouse and the mouse movement of the remote mouse. This simply makes it a bit less painful to use. + // When we get Pointer Lock support, we can remove this. + const containerElm = containerRef.current; + if (!containerElm) return; + + containerElm.addEventListener("mousemove", relMouseMoveHandler, { signal }); + containerElm.addEventListener("pointerdown", relMouseMoveHandler, { signal }); + containerElm.addEventListener("pointerup", relMouseMoveHandler, { signal }); + + containerElm.addEventListener("wheel", mouseWheelHandler, { + signal, + passive: true, + }); + + const preventContextMenu = (e: MouseEvent) => e.preventDefault(); + containerElm.addEventListener("contextmenu", preventContextMenu, { signal }); + + return () => { + abortController.abort(); + }; + }, + [settings.mouseMode, relMouseMoveHandler, mouseWheelHandler], + ); + return ( <div className="grid h-full w-full grid-rows-layout"> <div className="min-h-[39.5px]"> @@ -439,7 +524,12 @@ export default function WebRTCVideo() { </fieldset> </div> - <div className="h-full overflow-hidden"> + <div + ref={containerRef} + className={cx("h-full overflow-hidden", { + "cursor-none": settings.mouseMode === "relative" && settings.isCursorHidden, + })} + > <div className="relative h-full"> <div className={cx( @@ -468,7 +558,9 @@ export default function WebRTCVideo() { className={cx( "outline-50 max-h-full max-w-full object-contain transition-all duration-1000", { - "cursor-none": settings.isCursorHidden, + "cursor-none": + settings.mouseMode === "absolute" && + settings.isCursorHidden, "opacity-0": isLoading || isConnectionError || hdmiError, "animate-slideUpFade border border-slate-800/30 opacity-0 shadow dark:border-slate-300/20": isPlaying, diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index ac8ad7d..f30c28c 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -197,15 +197,23 @@ export const useRTCStore = create<RTCState>(set => ({ setTerminalChannel: channel => set({ terminalChannel: channel }), })); +interface MouseMove { + x: number; + y: number; + buttons: number; +} interface MouseState { mouseX: number; mouseY: number; + mouseMove?: MouseMove; + setMouseMove: (move?: MouseMove) => void; setMousePosition: (x: number, y: number) => void; } export const useMouseStore = create<MouseState>(set => ({ mouseX: 0, mouseY: 0, + setMouseMove: (move?: MouseMove) => set({ mouseMove: move }), setMousePosition: (x, y) => set({ mouseX: x, mouseY: y }), })); @@ -543,12 +551,12 @@ export interface UpdateState { setOtaState: (state: UpdateState["otaState"]) => void; setUpdateDialogHasBeenMinimized: (hasBeenMinimized: boolean) => void; modalView: - | "loading" - | "updating" - | "upToDate" - | "updateAvailable" - | "updateCompleted" - | "error"; + | "loading" + | "updating" + | "upToDate" + | "updateAvailable" + | "updateCompleted" + | "error"; setModalView: (view: UpdateState["modalView"]) => void; setUpdateErrorMessage: (errorMessage: string) => void; updateErrorMessage: string | null; @@ -612,12 +620,12 @@ export const useUsbConfigModalStore = create<UsbConfigModalState>(set => ({ interface LocalAuthModalState { modalView: - | "createPassword" - | "deletePassword" - | "updatePassword" - | "creationSuccess" - | "deleteSuccess" - | "updateSuccess"; + | "createPassword" + | "deletePassword" + | "updatePassword" + | "creationSuccess" + | "deleteSuccess" + | "updateSuccess"; setModalView: (view: LocalAuthModalState["modalView"]) => void; } diff --git a/ui/src/routes/devices.$id.mount.tsx b/ui/src/routes/devices.$id.mount.tsx index 1a0ace8..42be090 100644 --- a/ui/src/routes/devices.$id.mount.tsx +++ b/ui/src/routes/devices.$id.mount.tsx @@ -100,8 +100,6 @@ export function Dialog({ onClose }: { onClose: () => void }) { .finally(() => { setMountInProgress(false); }); - - navigate(".."); }); } @@ -115,7 +113,7 @@ export function Dialog({ onClose }: { onClose: () => void }) { clearMountMediaState(); syncRemoteVirtualMediaState() .then(() => { - false; + navigate(".."); }) .catch(err => { triggerError(err instanceof Error ? err.message : String(err)); @@ -839,7 +837,11 @@ function DeviceFileView({ onDelete={() => { const selectedFile = onStorageFiles.find(f => f.name === file.name); if (!selectedFile) return; - if (window.confirm("Are you sure you want to delete " + selectedFile.name + "?")) { + if ( + window.confirm( + "Are you sure you want to delete " + selectedFile.name + "?", + ) + ) { handleDeleteFile(selectedFile); } }} diff --git a/ui/src/routes/devices.$id.settings.hardware.tsx b/ui/src/routes/devices.$id.settings.hardware.tsx index 3a60466..d9d3919 100644 --- a/ui/src/routes/devices.$id.settings.hardware.tsx +++ b/ui/src/routes/devices.$id.settings.hardware.tsx @@ -6,7 +6,7 @@ import { useJsonRpc } from "@/hooks/useJsonRpc"; import notifications from "../notifications"; import { SelectMenuBasic } from "@components/SelectMenuBasic"; -import { UsbConfigSetting } from "../components/UsbConfigSetting"; +import { UsbInfoSetting } from "../components/UsbInfoSetting"; import { UsbDeviceSetting } from "@components/UsbDeviceSetting"; import { FeatureFlag } from "../components/FeatureFlag"; @@ -131,11 +131,11 @@ export default function SettingsHardwareRoute() { </div> <FeatureFlag minAppVersion="0.3.8"> - <UsbConfigSetting /> + <UsbDeviceSetting /> </FeatureFlag> <FeatureFlag minAppVersion="0.3.8"> - <UsbDeviceSetting /> + <UsbInfoSetting /> </FeatureFlag> </div> ); diff --git a/ui/src/routes/devices.$id.settings.mouse.tsx b/ui/src/routes/devices.$id.settings.mouse.tsx index c8c351a..1d3a6cd 100644 --- a/ui/src/routes/devices.$id.settings.mouse.tsx +++ b/ui/src/routes/devices.$id.settings.mouse.tsx @@ -1,23 +1,27 @@ -import { SettingsPageHeader } from "@components/SettingsPageheader"; -import { SettingsItem } from "./devices.$id.settings"; -import { Checkbox } from "@/components/Checkbox"; -import { GridCard } from "@/components/Card"; +import MouseIcon from "@/assets/mouse-icon.svg"; import PointingFinger from "@/assets/pointing-finger.svg"; -import { CheckCircleIcon } from "@heroicons/react/16/solid"; +import { GridCard } from "@/components/Card"; +import { Checkbox } from "@/components/Checkbox"; import { useDeviceSettingsStore, useSettingsStore } from "@/hooks/stores"; -import notifications from "@/notifications"; -import { useCallback, useEffect, useState } from "react"; import { useJsonRpc } from "@/hooks/useJsonRpc"; -import { cx } from "../cva.config"; +import notifications from "@/notifications"; +import { SettingsPageHeader } from "@components/SettingsPageheader"; +import { CheckCircleIcon } from "@heroicons/react/16/solid"; +import { useCallback, useEffect, useState } from "react"; +import { FeatureFlag } from "../components/FeatureFlag"; import { SelectMenuBasic } from "../components/SelectMenuBasic"; import { useFeatureFlag } from "../hooks/useFeatureFlag"; -import { FeatureFlag } from "../components/FeatureFlag"; +import { SettingsItem } from "./devices.$id.settings"; type ScrollSensitivity = "low" | "default" | "high"; export default function SettingsKeyboardMouseRoute() { const hideCursor = useSettingsStore(state => state.isCursorHidden); const setHideCursor = useSettingsStore(state => state.setCursorVisibility); + + const mouseMode = useSettingsStore(state => state.mouseMode); + const setMouseMode = useSettingsStore(state => state.setMouseMode); + const scrollSensitivity = useDeviceSettingsStore(state => state.scrollSensitivity); const setScrollSensitivity = useDeviceSettingsStore( state => state.setScrollSensitivity, @@ -122,19 +126,19 @@ export default function SettingsKeyboardMouseRoute() { </SettingsItem> <div className="space-y-4"> <SettingsItem title="Modes" description="Choose the mouse input mode" /> - <div className="flex flex-col items-center gap-4 md:flex-row"> + <div className="flex items-center gap-4"> <button - className="group block w-full grow" - onClick={() => console.log("Absolute mouse mode clicked")} + className="block group grow" + onClick={() => { setMouseMode("absolute"); }} > <GridCard> - <div className="group flex items-center gap-x-4 px-4 py-3"> + <div className="flex items-center px-4 py-3 group gap-x-4"> <img className="w-6 shrink-0 dark:invert" src={PointingFinger} alt="Finger touching a screen" /> - <div className="flex grow items-center justify-between"> + <div className="flex items-center justify-between grow"> <div className="text-left"> <h3 className="text-sm font-semibold text-black dark:text-white"> Absolute @@ -143,41 +147,32 @@ export default function SettingsKeyboardMouseRoute() { Most convenient </p> </div> - <CheckCircleIcon - className={cx( - "h-4 w-4 text-blue-700 transition-opacity duration-300 dark:text-blue-500", - )} - /> + {mouseMode === "absolute" && ( + <CheckCircleIcon className="w-4 h-4 text-blue-700 dark:text-blue-500" /> + )} </div> </div> </GridCard> </button> <button - className="group block w-full grow cursor-not-allowed opacity-50" - disabled + className="block group grow" + onClick={() => { setMouseMode("relative"); }} > <GridCard> - <div className="group flex items-center gap-x-4 px-4 py-3"> - <img - className="w-6 shrink-0 dark:invert" - src={PointingFinger} - alt="Finger touching a screen" - /> - <div className="flex grow items-center justify-between"> + <div className="flex items-center px-4 py-3 gap-x-4"> + <img className="w-6 shrink-0 dark:invert" src={MouseIcon} alt="Mouse icon" /> + <div className="flex items-center justify-between grow"> <div className="text-left"> <h3 className="text-sm font-semibold text-black dark:text-white"> Relative </h3> <p className="text-xs leading-none text-slate-800 dark:text-slate-300"> - Most Compatible + Most Compatible (Beta) </p> </div> - <CheckCircleIcon - className={cx( - "hidden", - "h-4 w-4 text-blue-700 transition-opacity duration-300 dark:text-blue-500", - )} - /> + {mouseMode === "relative" && ( + <CheckCircleIcon className="w-4 h-4 text-blue-700 dark:text-blue-500" /> + )} </div> </div> </GridCard> diff --git a/ui/src/routes/devices.$id.settings.tsx b/ui/src/routes/devices.$id.settings.tsx index 6084afb..1a8de03 100644 --- a/ui/src/routes/devices.$id.settings.tsx +++ b/ui/src/routes/devices.$id.settings.tsx @@ -16,6 +16,7 @@ import { cx } from "../cva.config"; import { useUiStore } from "../hooks/stores"; import useKeyboard from "../hooks/useKeyboard"; import { useResizeObserver } from "../hooks/useResizeObserver"; +import LoadingSpinner from "../components/LoadingSpinner"; /* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */ export default function SettingsRoute() { @@ -206,7 +207,7 @@ export default function SettingsRoute() { </div> </Card> </div> - <div className="w-full md:col-span-5"> + <div className="w-full md:col-span-6"> {/* <AutoHeight> */} <Card className="dark:bg-slate-800"> <div @@ -230,12 +231,14 @@ export function SettingsItem({ description, children, className, + loading, }: { title: string; description: string | React.ReactNode; children?: React.ReactNode; className?: string; name?: string; + loading?: boolean; }) { return ( <label @@ -245,7 +248,10 @@ export function SettingsItem({ )} > <div className="space-y-0.5"> - <h3 className="text-base font-semibold text-black dark:text-white">{title}</h3> + <div className="flex items-center gap-x-2"> + <h3 className="text-base font-semibold text-black dark:text-white">{title}</h3> + {loading && <LoadingSpinner className="h-4 w-4 text-blue-500" />} + </div> <p className="text-sm text-slate-700 dark:text-slate-300">{description}</p> </div> {children ? <div>{children}</div> : null} diff --git a/ui/src/routes/devices.$id.tsx b/ui/src/routes/devices.$id.tsx index 24a6428..d25b848 100644 --- a/ui/src/routes/devices.$id.tsx +++ b/ui/src/routes/devices.$id.tsx @@ -516,6 +516,10 @@ export default function KvmIdRoute() { <div className="isolate" + // onMouseMove={e => e.stopPropagation()} + // onMouseDown={e => e.stopPropagation()} + // onMouseUp={e => e.stopPropagation()} + // onPointerMove={e => e.stopPropagation()} onKeyUp={e => e.stopPropagation()} onKeyDown={e => { e.stopPropagation(); diff --git a/usb.go b/usb.go index 8da6737..8a3538b 100644 --- a/usb.go +++ b/usb.go @@ -1,8 +1,9 @@ package kvm import ( - "github.com/jetkvm/kvm/internal/usbgadget" "time" + + "github.com/jetkvm/kvm/internal/usbgadget" ) var gadget *usbgadget.UsbGadget @@ -33,6 +34,10 @@ func rpcAbsMouseReport(x, y int, buttons uint8) error { return gadget.AbsMouseReport(x, y, buttons) } +func rpcRelMouseReport(dx, dy int8, buttons uint8) error { + return gadget.RelMouseReport(dx, dy, buttons) +} + func rpcWheelReport(wheelY int8) error { return gadget.AbsMouseWheelReport(wheelY) } diff --git a/usb_mass_storage.go b/usb_mass_storage.go index 45f613f..6578069 100644 --- a/usb_mass_storage.go +++ b/usb_mass_storage.go @@ -15,9 +15,9 @@ import ( "time" "github.com/gin-gonic/gin" - "github.com/psanford/httpreadat" "github.com/google/uuid" "github.com/pion/webrtc/v4" + "github.com/psanford/httpreadat" "github.com/jetkvm/kvm/resource" ) @@ -27,7 +27,7 @@ func writeFile(path string, data string) error { } func setMassStorageImage(imagePath string) error { - massStorageFunctionPath, err := gadget.GetConfigPath("mass_storage_lun0") + massStorageFunctionPath, err := gadget.GetPath("mass_storage_lun0") if err != nil { return fmt.Errorf("failed to get mass storage path: %w", err) } @@ -39,7 +39,7 @@ func setMassStorageImage(imagePath string) error { } func setMassStorageMode(cdrom bool) error { - massStorageFunctionPath, err := gadget.GetConfigPath("mass_storage_lun0") + massStorageFunctionPath, err := gadget.GetPath("mass_storage_lun0") if err != nil { return fmt.Errorf("failed to get mass storage path: %w", err) } @@ -110,7 +110,7 @@ func rpcMountBuiltInImage(filename string) error { } func getMassStorageMode() (bool, error) { - massStorageFunctionPath, err := gadget.GetConfigPath("mass_storage_lun0") + massStorageFunctionPath, err := gadget.GetPath("mass_storage_lun0") if err != nil { return false, fmt.Errorf("failed to get mass storage path: %w", err) } diff --git a/web.go b/web.go index 2f24d15..8d01b7e 100644 --- a/web.go +++ b/web.go @@ -11,10 +11,7 @@ import ( "github.com/gin-gonic/gin" "github.com/google/uuid" - "github.com/prometheus/client_golang/prometheus" - versioncollector "github.com/prometheus/client_golang/prometheus/collectors/version" "github.com/prometheus/client_golang/prometheus/promhttp" - "github.com/prometheus/common/version" "golang.org/x/crypto/bcrypt" ) @@ -86,8 +83,6 @@ func setupRouter() *gin.Engine { r.POST("/device/setup", handleSetup) // A Prometheus metrics endpoint. - version.Version = builtAppVersion - prometheus.MustRegister(versioncollector.NewCollector("jetkvm")) r.GET("/metrics", gin.WrapH(promhttp.Handler())) // Protected routes (allows both password and noPassword modes) diff --git a/web_tls.go b/web_tls.go new file mode 100644 index 0000000..fff9253 --- /dev/null +++ b/web_tls.go @@ -0,0 +1,132 @@ +package kvm + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "log" + "math/big" + "net" + "net/http" + "strings" + "sync" + "time" +) + +const ( + WebSecureListen = ":443" + WebSecureSelfSignedDefaultDomain = "jetkvm.local" + WebSecureSelfSignedDuration = 365 * 24 * time.Hour +) + +var ( + tlsCerts = make(map[string]*tls.Certificate) + tlsCertLock = &sync.Mutex{} +) + +// RunWebSecureServer runs a web server with TLS. +func RunWebSecureServer() { + r := setupRouter() + + server := &http.Server{ + Addr: WebSecureListen, + Handler: r, + TLSConfig: &tls.Config{ + // TODO: cache certificate in persistent storage + GetCertificate: func(info *tls.ClientHelloInfo) (*tls.Certificate, error) { + hostname := WebSecureSelfSignedDefaultDomain + if info.ServerName != "" { + hostname = info.ServerName + } else { + hostname = strings.Split(info.Conn.LocalAddr().String(), ":")[0] + } + + logger.Infof("TLS handshake for %s, SupportedProtos: %v", hostname, info.SupportedProtos) + + cert := createSelfSignedCert(hostname) + + return cert, nil + }, + }, + } + logger.Infof("Starting websecure server on %s", RunWebSecureServer) + err := server.ListenAndServeTLS("", "") + if err != nil { + panic(err) + } + return +} + +func createSelfSignedCert(hostname string) *tls.Certificate { + if tlsCert := tlsCerts[hostname]; tlsCert != nil { + return tlsCert + } + tlsCertLock.Lock() + defer tlsCertLock.Unlock() + + logger.Infof("Creating self-signed certificate for %s", hostname) + + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + log.Fatalf("Failed to generate private key: %v", err) + } + keyUsage := x509.KeyUsageDigitalSignature + + notBefore := time.Now() + notAfter := notBefore.AddDate(1, 0, 0) + + serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) + serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) + if err != nil { + logger.Errorf("Failed to generate serial number: %v", err) + } + + dnsName := hostname + ip := net.ParseIP(hostname) + if ip != nil { + dnsName = WebSecureSelfSignedDefaultDomain + } + + template := x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + CommonName: hostname, + Organization: []string{"JetKVM"}, + }, + NotBefore: notBefore, + NotAfter: notAfter, + + KeyUsage: keyUsage, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + + DNSNames: []string{dnsName}, + IPAddresses: []net.IP{}, + } + + if ip != nil { + template.IPAddresses = append(template.IPAddresses, ip) + } + + derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) + if err != nil { + logger.Errorf("Failed to create certificate: %v", err) + } + + cert := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) + if cert == nil { + logger.Errorf("Failed to encode certificate") + } + + tlsCert := &tls.Certificate{ + Certificate: [][]byte{derBytes}, + PrivateKey: priv, + } + tlsCerts[hostname] = tlsCert + + return tlsCert +}