mirror of https://github.com/jetkvm/kvm.git
				
				
				
			feat: add local web server loopback mode configuration (#511)
* feat: add local web server loopback mode configuration - Introduced a new configuration option `LocalWebServerLoopbackOnly` to restrict the web server to listen only on the loopback interface. - Added RPC methods `rpcGetLocalWebServerLoopbackOnly` and `rpcSetLocalWebServerLoopbackOnly` for retrieving and updating this setting. - Updated the web server startup logic to bind to the appropriate address based on the new configuration. - Modified the `LocalDevice` struct to include the loopback setting in the response. * remove extra logs * chore: add VSCode extensions for improved development environment * refactor: rename LocalWebServerLoopbackOnly to LocalLoopbackOnly - Updated the configuration struct and related RPC methods to use the new name `LocalLoopbackOnly` for clarity. - Adjusted the web server binding logic and device response structure to reflect this change. * feat: add loopback-only mode functionality to UI - Implemented a new setting for enabling loopback-only mode, restricting web interface access to localhost. - Added a confirmation dialog to warn users before enabling this feature. - Updated the ConfirmDialog component to accept React nodes for the description prop. - Refactored imports and adjusted component structure for clarity. * refactor: optimize device settings handlers for better performance - Refactored the `handleDevChannelChange` and `handleLoopbackOnlyModeChange` functions to use `useCallback` for improved performance and to prevent unnecessary re-renders. - Consolidated the logic for applying loopback-only mode into a separate `applyLoopbackOnlyMode` function, enhancing code clarity and maintainability. - Updated the confirmation flow for enabling loopback-only mode to ensure user warnings are displayed appropriately.
This commit is contained in:
		
							parent
							
								
									1f7c5c94d8
								
							
						
					
					
						commit
						718b343713
					
				|  | @ -85,6 +85,7 @@ type Config struct { | ||||||
| 	HashedPassword       string                 `json:"hashed_password"` | 	HashedPassword       string                 `json:"hashed_password"` | ||||||
| 	LocalAuthToken       string                 `json:"local_auth_token"` | 	LocalAuthToken       string                 `json:"local_auth_token"` | ||||||
| 	LocalAuthMode        string                 `json:"localAuthMode"` //TODO: fix it with migration
 | 	LocalAuthMode        string                 `json:"localAuthMode"` //TODO: fix it with migration
 | ||||||
|  | 	LocalLoopbackOnly    bool                   `json:"local_loopback_only"` | ||||||
| 	WakeOnLanDevices     []WakeOnLanDevice      `json:"wake_on_lan_devices"` | 	WakeOnLanDevices     []WakeOnLanDevice      `json:"wake_on_lan_devices"` | ||||||
| 	KeyboardMacros       []KeyboardMacro        `json:"keyboard_macros"` | 	KeyboardMacros       []KeyboardMacro        `json:"keyboard_macros"` | ||||||
| 	KeyboardLayout       string                 `json:"keyboard_layout"` | 	KeyboardLayout       string                 `json:"keyboard_layout"` | ||||||
|  |  | ||||||
							
								
								
									
										21
									
								
								jsonrpc.go
								
								
								
								
							
							
						
						
									
										21
									
								
								jsonrpc.go
								
								
								
								
							|  | @ -1006,6 +1006,25 @@ func setKeyboardMacros(params KeyboardMacrosParams) (interface{}, error) { | ||||||
| 	return nil, nil | 	return nil, nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | func rpcGetLocalLoopbackOnly() (bool, error) { | ||||||
|  | 	return config.LocalLoopbackOnly, nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func rpcSetLocalLoopbackOnly(enabled bool) error { | ||||||
|  | 	// Check if the setting is actually changing
 | ||||||
|  | 	if config.LocalLoopbackOnly == enabled { | ||||||
|  | 		return nil | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Update the setting
 | ||||||
|  | 	config.LocalLoopbackOnly = enabled | ||||||
|  | 	if err := SaveConfig(); err != nil { | ||||||
|  | 		return fmt.Errorf("failed to save config: %w", err) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	return nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
| var rpcHandlers = map[string]RPCHandler{ | var rpcHandlers = map[string]RPCHandler{ | ||||||
| 	"ping":                   {Func: rpcPing}, | 	"ping":                   {Func: rpcPing}, | ||||||
| 	"reboot":                 {Func: rpcReboot, Params: []string{"force"}}, | 	"reboot":                 {Func: rpcReboot, Params: []string{"force"}}, | ||||||
|  | @ -1083,4 +1102,6 @@ var rpcHandlers = map[string]RPCHandler{ | ||||||
| 	"setKeyboardLayout":      {Func: rpcSetKeyboardLayout, Params: []string{"layout"}}, | 	"setKeyboardLayout":      {Func: rpcSetKeyboardLayout, Params: []string{"layout"}}, | ||||||
| 	"getKeyboardMacros":      {Func: getKeyboardMacros}, | 	"getKeyboardMacros":      {Func: getKeyboardMacros}, | ||||||
| 	"setKeyboardMacros":      {Func: setKeyboardMacros, Params: []string{"params"}}, | 	"setKeyboardMacros":      {Func: setKeyboardMacros, Params: []string{"params"}}, | ||||||
|  | 	"getLocalLoopbackOnly":   {Func: rpcGetLocalLoopbackOnly}, | ||||||
|  | 	"setLocalLoopbackOnly":   {Func: rpcSetLocalLoopbackOnly, Params: []string{"enabled"}}, | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -1,12 +1,12 @@ | ||||||
| import { | import { | ||||||
|   ExclamationTriangleIcon, |  | ||||||
|   CheckCircleIcon, |   CheckCircleIcon, | ||||||
|  |   ExclamationTriangleIcon, | ||||||
|   InformationCircleIcon, |   InformationCircleIcon, | ||||||
| } from "@heroicons/react/24/outline"; | } from "@heroicons/react/24/outline"; | ||||||
| 
 | 
 | ||||||
| import { cx } from "@/cva.config"; |  | ||||||
| import { Button } from "@/components/Button"; | import { Button } from "@/components/Button"; | ||||||
| import Modal from "@/components/Modal"; | import Modal from "@/components/Modal"; | ||||||
|  | import { cx } from "@/cva.config"; | ||||||
| 
 | 
 | ||||||
| type Variant = "danger" | "success" | "warning" | "info"; | type Variant = "danger" | "success" | "warning" | "info"; | ||||||
| 
 | 
 | ||||||
|  | @ -14,7 +14,7 @@ interface ConfirmDialogProps { | ||||||
|   open: boolean; |   open: boolean; | ||||||
|   onClose: () => void; |   onClose: () => void; | ||||||
|   title: string; |   title: string; | ||||||
|   description: string; |   description: React.ReactNode; | ||||||
|   variant?: Variant; |   variant?: Variant; | ||||||
|   confirmText?: string; |   confirmText?: string; | ||||||
|   cancelText?: string | null; |   cancelText?: string | null; | ||||||
|  | @ -84,8 +84,8 @@ export function ConfirmDialog({ | ||||||
|               > |               > | ||||||
|                 <Icon aria-hidden="true" className={cx("size-6", iconClass)} /> |                 <Icon aria-hidden="true" className={cx("size-6", iconClass)} /> | ||||||
|               </div> |               </div> | ||||||
|               <div className="mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left"> |               <div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"> | ||||||
|                 <h2 className="text-lg font-bold leading-tight text-black dark:text-white"> |                 <h2 className="text-lg leading-tight font-bold text-black dark:text-white"> | ||||||
|                   {title} |                   {title} | ||||||
|                 </h2> |                 </h2> | ||||||
|                 <div className="mt-2 text-sm leading-snug text-slate-600 dark:text-slate-400"> |                 <div className="mt-2 text-sm leading-snug text-slate-600 dark:text-slate-400"> | ||||||
|  | @ -111,4 +111,4 @@ export function ConfirmDialog({ | ||||||
|       </div> |       </div> | ||||||
|     </Modal> |     </Modal> | ||||||
|   ); |   ); | ||||||
| } | } | ||||||
|  | @ -1,17 +1,16 @@ | ||||||
| 
 | import { useCallback, useEffect, useState } from "react"; | ||||||
| import { useCallback, useState, useEffect } from "react"; |  | ||||||
| 
 | 
 | ||||||
| import { GridCard } from "@components/Card"; | import { GridCard } from "@components/Card"; | ||||||
| 
 | 
 | ||||||
| import { SettingsPageHeader } from "../components/SettingsPageheader"; |  | ||||||
| import Checkbox from "../components/Checkbox"; |  | ||||||
| import { useJsonRpc } from "../hooks/useJsonRpc"; |  | ||||||
| import notifications from "../notifications"; |  | ||||||
| import { TextAreaWithLabel } from "../components/TextArea"; |  | ||||||
| import { isOnDevice } from "../main"; |  | ||||||
| import { Button } from "../components/Button"; | import { Button } from "../components/Button"; | ||||||
|  | import Checkbox from "../components/Checkbox"; | ||||||
|  | import { ConfirmDialog } from "../components/ConfirmDialog"; | ||||||
|  | import { SettingsPageHeader } from "../components/SettingsPageheader"; | ||||||
|  | import { TextAreaWithLabel } from "../components/TextArea"; | ||||||
| import { useSettingsStore } from "../hooks/stores"; | import { useSettingsStore } from "../hooks/stores"; | ||||||
| 
 | import { useJsonRpc } from "../hooks/useJsonRpc"; | ||||||
|  | import { isOnDevice } from "../main"; | ||||||
|  | import notifications from "../notifications"; | ||||||
| 
 | 
 | ||||||
| import { SettingsItem } from "./devices.$id.settings"; | import { SettingsItem } from "./devices.$id.settings"; | ||||||
| 
 | 
 | ||||||
|  | @ -22,6 +21,8 @@ export default function SettingsAdvancedRoute() { | ||||||
|   const setDeveloperMode = useSettingsStore(state => state.setDeveloperMode); |   const setDeveloperMode = useSettingsStore(state => state.setDeveloperMode); | ||||||
|   const [devChannel, setDevChannel] = useState(false); |   const [devChannel, setDevChannel] = useState(false); | ||||||
|   const [usbEmulationEnabled, setUsbEmulationEnabled] = useState(false); |   const [usbEmulationEnabled, setUsbEmulationEnabled] = useState(false); | ||||||
|  |   const [showLoopbackWarning, setShowLoopbackWarning] = useState(false); | ||||||
|  |   const [localLoopbackOnly, setLocalLoopbackOnly] = useState(false); | ||||||
| 
 | 
 | ||||||
|   const settings = useSettingsStore(); |   const settings = useSettingsStore(); | ||||||
| 
 | 
 | ||||||
|  | @ -46,6 +47,11 @@ export default function SettingsAdvancedRoute() { | ||||||
|       if ("error" in resp) return; |       if ("error" in resp) return; | ||||||
|       setDevChannel(resp.result as boolean); |       setDevChannel(resp.result as boolean); | ||||||
|     }); |     }); | ||||||
|  | 
 | ||||||
|  |     send("getLocalLoopbackOnly", {}, resp => { | ||||||
|  |       if ("error" in resp) return; | ||||||
|  |       setLocalLoopbackOnly(resp.result as boolean); | ||||||
|  |     }); | ||||||
|   }, [send, setDeveloperMode]); |   }, [send, setDeveloperMode]); | ||||||
| 
 | 
 | ||||||
|   const getUsbEmulationState = useCallback(() => { |   const getUsbEmulationState = useCallback(() => { | ||||||
|  | @ -110,17 +116,62 @@ export default function SettingsAdvancedRoute() { | ||||||
|     [send, setDeveloperMode], |     [send, setDeveloperMode], | ||||||
|   ); |   ); | ||||||
| 
 | 
 | ||||||
|   const handleDevChannelChange = (enabled: boolean) => { |   const handleDevChannelChange = useCallback( | ||||||
|     send("setDevChannelState", { enabled }, resp => { |     (enabled: boolean) => { | ||||||
|       if ("error" in resp) { |       send("setDevChannelState", { enabled }, resp => { | ||||||
|         notifications.error( |         if ("error" in resp) { | ||||||
|           `Failed to set dev channel state: ${resp.error.data || "Unknown error"}`, |           notifications.error( | ||||||
|         ); |             `Failed to set dev channel state: ${resp.error.data || "Unknown error"}`, | ||||||
|         return; |           ); | ||||||
|  |           return; | ||||||
|  |         } | ||||||
|  |         setDevChannel(enabled); | ||||||
|  |       }); | ||||||
|  |     }, | ||||||
|  |     [send, setDevChannel], | ||||||
|  |   ); | ||||||
|  | 
 | ||||||
|  |   const applyLoopbackOnlyMode = useCallback( | ||||||
|  |     (enabled: boolean) => { | ||||||
|  |       send("setLocalLoopbackOnly", { enabled }, resp => { | ||||||
|  |         if ("error" in resp) { | ||||||
|  |           notifications.error( | ||||||
|  |             `Failed to ${enabled ? "enable" : "disable"} loopback-only mode: ${resp.error.data || "Unknown error"}`, | ||||||
|  |           ); | ||||||
|  |           return; | ||||||
|  |         } | ||||||
|  |         setLocalLoopbackOnly(enabled); | ||||||
|  |         if (enabled) { | ||||||
|  |           notifications.success( | ||||||
|  |             "Loopback-only mode enabled. Restart your device to apply.", | ||||||
|  |           ); | ||||||
|  |         } else { | ||||||
|  |           notifications.success( | ||||||
|  |             "Loopback-only mode disabled. Restart your device to apply.", | ||||||
|  |           ); | ||||||
|  |         } | ||||||
|  |       }); | ||||||
|  |     }, | ||||||
|  |     [send, setLocalLoopbackOnly], | ||||||
|  |   ); | ||||||
|  | 
 | ||||||
|  |   const handleLoopbackOnlyModeChange = useCallback( | ||||||
|  |     (enabled: boolean) => { | ||||||
|  |       // If trying to enable loopback-only mode, show warning first
 | ||||||
|  |       if (enabled) { | ||||||
|  |         setShowLoopbackWarning(true); | ||||||
|  |       } else { | ||||||
|  |         // If disabling, just proceed
 | ||||||
|  |         applyLoopbackOnlyMode(false); | ||||||
|       } |       } | ||||||
|       setDevChannel(enabled); |     }, | ||||||
|     }); |     [applyLoopbackOnlyMode, setShowLoopbackWarning], | ||||||
|   }; |   ); | ||||||
|  | 
 | ||||||
|  |   const confirmLoopbackModeEnable = useCallback(() => { | ||||||
|  |     applyLoopbackOnlyMode(true); | ||||||
|  |     setShowLoopbackWarning(false); | ||||||
|  |   }, [applyLoopbackOnlyMode, setShowLoopbackWarning]); | ||||||
| 
 | 
 | ||||||
|   return ( |   return ( | ||||||
|     <div className="space-y-4"> |     <div className="space-y-4"> | ||||||
|  | @ -153,7 +204,7 @@ export default function SettingsAdvancedRoute() { | ||||||
| 
 | 
 | ||||||
|         {settings.developerMode && ( |         {settings.developerMode && ( | ||||||
|           <GridCard> |           <GridCard> | ||||||
|             <div className="flex select-none items-start gap-x-4 p-4"> |             <div className="flex items-start gap-x-4 p-4 select-none"> | ||||||
|               <svg |               <svg | ||||||
|                 xmlns="http://www.w3.org/2000/svg" |                 xmlns="http://www.w3.org/2000/svg" | ||||||
|                 viewBox="0 0 24 24" |                 viewBox="0 0 24 24" | ||||||
|  | @ -187,6 +238,16 @@ export default function SettingsAdvancedRoute() { | ||||||
|           </GridCard> |           </GridCard> | ||||||
|         )} |         )} | ||||||
| 
 | 
 | ||||||
|  |         <SettingsItem | ||||||
|  |           title="Loopback-Only Mode" | ||||||
|  |           description="Restrict web interface access to localhost only (127.0.0.1)" | ||||||
|  |         > | ||||||
|  |           <Checkbox | ||||||
|  |             checked={localLoopbackOnly} | ||||||
|  |             onChange={e => handleLoopbackOnlyModeChange(e.target.checked)} | ||||||
|  |           /> | ||||||
|  |         </SettingsItem> | ||||||
|  | 
 | ||||||
|         {isOnDevice && settings.developerMode && ( |         {isOnDevice && settings.developerMode && ( | ||||||
|           <div className="space-y-4"> |           <div className="space-y-4"> | ||||||
|             <SettingsItem |             <SettingsItem | ||||||
|  | @ -261,6 +322,30 @@ export default function SettingsAdvancedRoute() { | ||||||
|           </> |           </> | ||||||
|         )} |         )} | ||||||
|       </div> |       </div> | ||||||
|  | 
 | ||||||
|  |       <ConfirmDialog | ||||||
|  |         open={showLoopbackWarning} | ||||||
|  |         onClose={() => { | ||||||
|  |           setShowLoopbackWarning(false); | ||||||
|  |         }} | ||||||
|  |         title="Enable Loopback-Only Mode?" | ||||||
|  |         description={ | ||||||
|  |           <> | ||||||
|  |             <p> | ||||||
|  |               WARNING: This will restrict web interface access to localhost (127.0.0.1) | ||||||
|  |               only. | ||||||
|  |             </p> | ||||||
|  |             <p>Before enabling this feature, make sure you have either:</p> | ||||||
|  |             <ul className="list-disc space-y-1 pl-5 text-xs text-slate-700 dark:text-slate-300"> | ||||||
|  |               <li>SSH access configured and tested</li> | ||||||
|  |               <li>Cloud access enabled and working</li> | ||||||
|  |             </ul> | ||||||
|  |           </> | ||||||
|  |         } | ||||||
|  |         variant="warning" | ||||||
|  |         confirmText="I Understand, Enable Anyway" | ||||||
|  |         onConfirm={confirmLoopbackModeEnable} | ||||||
|  |       /> | ||||||
|     </div> |     </div> | ||||||
|   ); |   ); | ||||||
| } | } | ||||||
|  |  | ||||||
							
								
								
									
										20
									
								
								web.go
								
								
								
								
							
							
						
						
									
										20
									
								
								web.go
								
								
								
								
							|  | @ -52,8 +52,9 @@ type ChangePasswordRequest struct { | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| type LocalDevice struct { | type LocalDevice struct { | ||||||
| 	AuthMode *string `json:"authMode"` | 	AuthMode     *string `json:"authMode"` | ||||||
| 	DeviceID string  `json:"deviceId"` | 	DeviceID     string  `json:"deviceId"` | ||||||
|  | 	LoopbackOnly bool    `json:"loopbackOnly"` | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| type DeviceStatus struct { | type DeviceStatus struct { | ||||||
|  | @ -532,7 +533,15 @@ func basicAuthProtectedMiddleware(requireDeveloperMode bool) gin.HandlerFunc { | ||||||
| 
 | 
 | ||||||
| func RunWebServer() { | func RunWebServer() { | ||||||
| 	r := setupRouter() | 	r := setupRouter() | ||||||
| 	err := r.Run(":80") | 
 | ||||||
|  | 	// Determine the binding address based on the config
 | ||||||
|  | 	bindAddress := ":80" // Default to all interfaces
 | ||||||
|  | 	if config.LocalLoopbackOnly { | ||||||
|  | 		bindAddress = "localhost:80" // Loopback only (both IPv4 and IPv6)
 | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	logger.Info().Str("bindAddress", bindAddress).Bool("loopbackOnly", config.LocalLoopbackOnly).Msg("Starting web server") | ||||||
|  | 	err := r.Run(bindAddress) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		panic(err) | 		panic(err) | ||||||
| 	} | 	} | ||||||
|  | @ -540,8 +549,9 @@ func RunWebServer() { | ||||||
| 
 | 
 | ||||||
| func handleDevice(c *gin.Context) { | func handleDevice(c *gin.Context) { | ||||||
| 	response := LocalDevice{ | 	response := LocalDevice{ | ||||||
| 		AuthMode: &config.LocalAuthMode, | 		AuthMode:     &config.LocalAuthMode, | ||||||
| 		DeviceID: GetDeviceID(), | 		DeviceID:     GetDeviceID(), | ||||||
|  | 		LoopbackOnly: config.LocalLoopbackOnly, | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	c.JSON(http.StatusOK, response) | 	c.JSON(http.StatusOK, response) | ||||||
|  |  | ||||||
		Loading…
	
		Reference in New Issue