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