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:
Alex Goodkind 2025-05-27 08:28:51 -07:00 committed by GitHub
parent 1f7c5c94d8
commit 718b343713
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 148 additions and 31 deletions

View File

@ -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"`

View File

@ -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"}},
} }

View File

@ -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>
); );
} }

View File

@ -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
View File

@ -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)