Skip to content

Commit 718b343

Browse files
authored
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.
1 parent 1f7c5c9 commit 718b343

File tree

5 files changed

+147
-30
lines changed

5 files changed

+147
-30
lines changed

config.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ type Config struct {
8585
HashedPassword string `json:"hashed_password"`
8686
LocalAuthToken string `json:"local_auth_token"`
8787
LocalAuthMode string `json:"localAuthMode"` //TODO: fix it with migration
88+
LocalLoopbackOnly bool `json:"local_loopback_only"`
8889
WakeOnLanDevices []WakeOnLanDevice `json:"wake_on_lan_devices"`
8990
KeyboardMacros []KeyboardMacro `json:"keyboard_macros"`
9091
KeyboardLayout string `json:"keyboard_layout"`

jsonrpc.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1006,6 +1006,25 @@ func setKeyboardMacros(params KeyboardMacrosParams) (interface{}, error) {
10061006
return nil, nil
10071007
}
10081008

1009+
func rpcGetLocalLoopbackOnly() (bool, error) {
1010+
return config.LocalLoopbackOnly, nil
1011+
}
1012+
1013+
func rpcSetLocalLoopbackOnly(enabled bool) error {
1014+
// Check if the setting is actually changing
1015+
if config.LocalLoopbackOnly == enabled {
1016+
return nil
1017+
}
1018+
1019+
// Update the setting
1020+
config.LocalLoopbackOnly = enabled
1021+
if err := SaveConfig(); err != nil {
1022+
return fmt.Errorf("failed to save config: %w", err)
1023+
}
1024+
1025+
return nil
1026+
}
1027+
10091028
var rpcHandlers = map[string]RPCHandler{
10101029
"ping": {Func: rpcPing},
10111030
"reboot": {Func: rpcReboot, Params: []string{"force"}},
@@ -1083,4 +1102,6 @@ var rpcHandlers = map[string]RPCHandler{
10831102
"setKeyboardLayout": {Func: rpcSetKeyboardLayout, Params: []string{"layout"}},
10841103
"getKeyboardMacros": {Func: getKeyboardMacros},
10851104
"setKeyboardMacros": {Func: setKeyboardMacros, Params: []string{"params"}},
1105+
"getLocalLoopbackOnly": {Func: rpcGetLocalLoopbackOnly},
1106+
"setLocalLoopbackOnly": {Func: rpcSetLocalLoopbackOnly, Params: []string{"enabled"}},
10861107
}

ui/src/components/ConfirmDialog.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,20 @@
11
import {
2-
ExclamationTriangleIcon,
32
CheckCircleIcon,
3+
ExclamationTriangleIcon,
44
InformationCircleIcon,
55
} from "@heroicons/react/24/outline";
66

7-
import { cx } from "@/cva.config";
87
import { Button } from "@/components/Button";
98
import Modal from "@/components/Modal";
9+
import { cx } from "@/cva.config";
1010

1111
type Variant = "danger" | "success" | "warning" | "info";
1212

1313
interface ConfirmDialogProps {
1414
open: boolean;
1515
onClose: () => void;
1616
title: string;
17-
description: string;
17+
description: React.ReactNode;
1818
variant?: Variant;
1919
confirmText?: string;
2020
cancelText?: string | null;
@@ -84,8 +84,8 @@ export function ConfirmDialog({
8484
>
8585
<Icon aria-hidden="true" className={cx("size-6", iconClass)} />
8686
</div>
87-
<div className="mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left">
88-
<h2 className="text-lg font-bold leading-tight text-black dark:text-white">
87+
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
88+
<h2 className="text-lg leading-tight font-bold text-black dark:text-white">
8989
{title}
9090
</h2>
9191
<div className="mt-2 text-sm leading-snug text-slate-600 dark:text-slate-400">
@@ -111,4 +111,4 @@ export function ConfirmDialog({
111111
</div>
112112
</Modal>
113113
);
114-
}
114+
}

ui/src/routes/devices.$id.settings.advanced.tsx

Lines changed: 104 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,16 @@
1-
2-
import { useCallback, useState, useEffect } from "react";
1+
import { useCallback, useEffect, useState } from "react";
32

43
import { GridCard } from "@components/Card";
54

6-
import { SettingsPageHeader } from "../components/SettingsPageheader";
5+
import { Button } from "../components/Button";
76
import Checkbox from "../components/Checkbox";
8-
import { useJsonRpc } from "../hooks/useJsonRpc";
9-
import notifications from "../notifications";
7+
import { ConfirmDialog } from "../components/ConfirmDialog";
8+
import { SettingsPageHeader } from "../components/SettingsPageheader";
109
import { TextAreaWithLabel } from "../components/TextArea";
11-
import { isOnDevice } from "../main";
12-
import { Button } from "../components/Button";
1310
import { useSettingsStore } from "../hooks/stores";
14-
11+
import { useJsonRpc } from "../hooks/useJsonRpc";
12+
import { isOnDevice } from "../main";
13+
import notifications from "../notifications";
1514

1615
import { SettingsItem } from "./devices.$id.settings";
1716

@@ -22,6 +21,8 @@ export default function SettingsAdvancedRoute() {
2221
const setDeveloperMode = useSettingsStore(state => state.setDeveloperMode);
2322
const [devChannel, setDevChannel] = useState(false);
2423
const [usbEmulationEnabled, setUsbEmulationEnabled] = useState(false);
24+
const [showLoopbackWarning, setShowLoopbackWarning] = useState(false);
25+
const [localLoopbackOnly, setLocalLoopbackOnly] = useState(false);
2526

2627
const settings = useSettingsStore();
2728

@@ -46,6 +47,11 @@ export default function SettingsAdvancedRoute() {
4647
if ("error" in resp) return;
4748
setDevChannel(resp.result as boolean);
4849
});
50+
51+
send("getLocalLoopbackOnly", {}, resp => {
52+
if ("error" in resp) return;
53+
setLocalLoopbackOnly(resp.result as boolean);
54+
});
4955
}, [send, setDeveloperMode]);
5056

5157
const getUsbEmulationState = useCallback(() => {
@@ -110,17 +116,62 @@ export default function SettingsAdvancedRoute() {
110116
[send, setDeveloperMode],
111117
);
112118

113-
const handleDevChannelChange = (enabled: boolean) => {
114-
send("setDevChannelState", { enabled }, resp => {
115-
if ("error" in resp) {
116-
notifications.error(
117-
`Failed to set dev channel state: ${resp.error.data || "Unknown error"}`,
118-
);
119-
return;
119+
const handleDevChannelChange = useCallback(
120+
(enabled: boolean) => {
121+
send("setDevChannelState", { enabled }, resp => {
122+
if ("error" in resp) {
123+
notifications.error(
124+
`Failed to set dev channel state: ${resp.error.data || "Unknown error"}`,
125+
);
126+
return;
127+
}
128+
setDevChannel(enabled);
129+
});
130+
},
131+
[send, setDevChannel],
132+
);
133+
134+
const applyLoopbackOnlyMode = useCallback(
135+
(enabled: boolean) => {
136+
send("setLocalLoopbackOnly", { enabled }, resp => {
137+
if ("error" in resp) {
138+
notifications.error(
139+
`Failed to ${enabled ? "enable" : "disable"} loopback-only mode: ${resp.error.data || "Unknown error"}`,
140+
);
141+
return;
142+
}
143+
setLocalLoopbackOnly(enabled);
144+
if (enabled) {
145+
notifications.success(
146+
"Loopback-only mode enabled. Restart your device to apply.",
147+
);
148+
} else {
149+
notifications.success(
150+
"Loopback-only mode disabled. Restart your device to apply.",
151+
);
152+
}
153+
});
154+
},
155+
[send, setLocalLoopbackOnly],
156+
);
157+
158+
const handleLoopbackOnlyModeChange = useCallback(
159+
(enabled: boolean) => {
160+
// If trying to enable loopback-only mode, show warning first
161+
if (enabled) {
162+
setShowLoopbackWarning(true);
163+
} else {
164+
// If disabling, just proceed
165+
applyLoopbackOnlyMode(false);
120166
}
121-
setDevChannel(enabled);
122-
});
123-
};
167+
},
168+
[applyLoopbackOnlyMode, setShowLoopbackWarning],
169+
);
170+
171+
const confirmLoopbackModeEnable = useCallback(() => {
172+
applyLoopbackOnlyMode(true);
173+
setShowLoopbackWarning(false);
174+
}, [applyLoopbackOnlyMode, setShowLoopbackWarning]);
124175

125176
return (
126177
<div className="space-y-4">
@@ -153,7 +204,7 @@ export default function SettingsAdvancedRoute() {
153204

154205
{settings.developerMode && (
155206
<GridCard>
156-
<div className="flex select-none items-start gap-x-4 p-4">
207+
<div className="flex items-start gap-x-4 p-4 select-none">
157208
<svg
158209
xmlns="http://www.w3.org/2000/svg"
159210
viewBox="0 0 24 24"
@@ -187,6 +238,16 @@ export default function SettingsAdvancedRoute() {
187238
</GridCard>
188239
)}
189240

241+
<SettingsItem
242+
title="Loopback-Only Mode"
243+
description="Restrict web interface access to localhost only (127.0.0.1)"
244+
>
245+
<Checkbox
246+
checked={localLoopbackOnly}
247+
onChange={e => handleLoopbackOnlyModeChange(e.target.checked)}
248+
/>
249+
</SettingsItem>
250+
190251
{isOnDevice && settings.developerMode && (
191252
<div className="space-y-4">
192253
<SettingsItem
@@ -261,6 +322,30 @@ export default function SettingsAdvancedRoute() {
261322
</>
262323
)}
263324
</div>
325+
326+
<ConfirmDialog
327+
open={showLoopbackWarning}
328+
onClose={() => {
329+
setShowLoopbackWarning(false);
330+
}}
331+
title="Enable Loopback-Only Mode?"
332+
description={
333+
<>
334+
<p>
335+
WARNING: This will restrict web interface access to localhost (127.0.0.1)
336+
only.
337+
</p>
338+
<p>Before enabling this feature, make sure you have either:</p>
339+
<ul className="list-disc space-y-1 pl-5 text-xs text-slate-700 dark:text-slate-300">
340+
<li>SSH access configured and tested</li>
341+
<li>Cloud access enabled and working</li>
342+
</ul>
343+
</>
344+
}
345+
variant="warning"
346+
confirmText="I Understand, Enable Anyway"
347+
onConfirm={confirmLoopbackModeEnable}
348+
/>
264349
</div>
265350
);
266351
}

web.go

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,9 @@ type ChangePasswordRequest struct {
5252
}
5353

5454
type LocalDevice struct {
55-
AuthMode *string `json:"authMode"`
56-
DeviceID string `json:"deviceId"`
55+
AuthMode *string `json:"authMode"`
56+
DeviceID string `json:"deviceId"`
57+
LoopbackOnly bool `json:"loopbackOnly"`
5758
}
5859

5960
type DeviceStatus struct {
@@ -532,16 +533,25 @@ func basicAuthProtectedMiddleware(requireDeveloperMode bool) gin.HandlerFunc {
532533

533534
func RunWebServer() {
534535
r := setupRouter()
535-
err := r.Run(":80")
536+
537+
// Determine the binding address based on the config
538+
bindAddress := ":80" // Default to all interfaces
539+
if config.LocalLoopbackOnly {
540+
bindAddress = "localhost:80" // Loopback only (both IPv4 and IPv6)
541+
}
542+
543+
logger.Info().Str("bindAddress", bindAddress).Bool("loopbackOnly", config.LocalLoopbackOnly).Msg("Starting web server")
544+
err := r.Run(bindAddress)
536545
if err != nil {
537546
panic(err)
538547
}
539548
}
540549

541550
func handleDevice(c *gin.Context) {
542551
response := LocalDevice{
543-
AuthMode: &config.LocalAuthMode,
544-
DeviceID: GetDeviceID(),
552+
AuthMode: &config.LocalAuthMode,
553+
DeviceID: GetDeviceID(),
554+
LoopbackOnly: config.LocalLoopbackOnly,
545555
}
546556

547557
c.JSON(http.StatusOK, response)

0 commit comments

Comments
 (0)