From 51f5eede86bf772392d96b0ae21a9af38ffbf81b Mon Sep 17 00:00:00 2001 From: zach Date: Sat, 20 Jun 2026 12:20:56 -0400 Subject: [PATCH] Add Tailscale-Only Mode toggle to web interface Adds a new 'Tailscale-Only Mode' toggle below 'Loopback-Only Mode' in Advanced Settings. When enabled, the web server binds to the device's Tailscale IP address, restricting access to the tailnet only. - config.go: Add LocalTailscaleOnly field - jsonrpc.go: Add get/setLocalTailscaleOnly RPC handlers - web.go: Bind to tailscale IP in getBindAddress(); add to device info - web_tls.go: Include tailscale-only in startup log - Advanced settings: Conditional toggle + warning dialog - en.json: 12 new i18n keys --- config.go | 1 + jsonrpc.go | 21 +++++ ui/localization/messages/en.json | 12 +++ .../routes/devices.$id.settings.advanced.tsx | 85 +++++++++++++++++++ web.go | 38 +++++++-- web_tls.go | 2 +- 6 files changed, 151 insertions(+), 8 deletions(-) diff --git a/config.go b/config.go index 32b3b659b..8f58bf601 100644 --- a/config.go +++ b/config.go @@ -102,6 +102,7 @@ type Config struct { LocalAuthToken string `json:"local_auth_token"` LocalAuthMode string `json:"localAuthMode"` //TODO: fix it with migration LocalLoopbackOnly bool `json:"local_loopback_only"` + LocalTailscaleOnly bool `json:"local_tailscale_only"` WakeOnLanDevices []WakeOnLanDevice `json:"wake_on_lan_devices"` KeyboardMacros []KeyboardMacro `json:"keyboard_macros"` KeyboardLayout string `json:"keyboard_layout"` diff --git a/jsonrpc.go b/jsonrpc.go index 7a60656e1..9ac749b7f 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -1175,6 +1175,25 @@ func rpcSetLocalLoopbackOnly(enabled bool) error { return nil } +func rpcGetLocalTailscaleOnly() (bool, error) { + return config.LocalTailscaleOnly, nil +} + +func rpcSetLocalTailscaleOnly(enabled bool) error { + // Check if the setting is actually changing + if config.LocalTailscaleOnly == enabled { + return nil + } + + // Update the setting + config.LocalTailscaleOnly = enabled + if err := SaveConfig(); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + return nil +} + var validLogLevels = map[string]bool{ "TRACE": true, "DEBUG": true, @@ -1431,6 +1450,8 @@ var rpcHandlers = map[string]RPCHandler{ "setKeyboardMacros": {Func: setKeyboardMacros, Params: []string{"params"}}, "getLocalLoopbackOnly": {Func: rpcGetLocalLoopbackOnly}, "setLocalLoopbackOnly": {Func: rpcSetLocalLoopbackOnly, Params: []string{"enabled"}}, + "getLocalTailscaleOnly": {Func: rpcGetLocalTailscaleOnly}, + "setLocalTailscaleOnly": {Func: rpcSetLocalTailscaleOnly, Params: []string{"enabled"}}, "getDefaultLogLevel": {Func: rpcGetDefaultLogLevel}, "setDefaultLogLevel": {Func: rpcSetDefaultLogLevel, Params: []string{"level"}}, "emitTestLog": {Func: rpcEmitTestLog, Params: []string{"level"}}, diff --git a/ui/localization/messages/en.json b/ui/localization/messages/en.json index 63be6e8d9..5fd6343d3 100644 --- a/ui/localization/messages/en.json +++ b/ui/localization/messages/en.json @@ -103,6 +103,18 @@ "advanced_loopback_warning_description": "WARNING: This will restrict web interface access to localhost (127.0.0.1) only.", "advanced_loopback_warning_ssh": "SSH access configured and tested", "advanced_loopback_warning_title": "Enable Loopback-Only Mode?", + "advanced_error_tailscale_disable": "Failed to disable tailscale-only mode: {error}", + "advanced_error_tailscale_enable": "Failed to enable tailscale-only mode: {error}", + "advanced_success_tailscale_disabled": "Tailscale-only mode disabled. Restart your device to apply.", + "advanced_success_tailscale_enabled": "Tailscale-only mode enabled. Restart your device to apply.", + "advanced_tailscale_only_description": "Restrict web interface access to the device's Tailscale IP only", + "advanced_tailscale_only_title": "Tailscale-Only Mode", + "advanced_tailscale_warning_before": "Before enabling this feature, make sure you have either:", + "advanced_tailscale_warning_cloud": "Cloud access enabled and working", + "advanced_tailscale_warning_confirm": "I Understand, Enable Anyway", + "advanced_tailscale_warning_description": "WARNING: This will restrict web interface access to the device's Tailscale IP only. The interface will not be accessible from the local network.", + "advanced_tailscale_warning_ssh": "SSH access configured and tested", + "advanced_tailscale_warning_title": "Enable Tailscale-Only Mode?", "advanced_reset_config_button": "Reset Config", "advanced_reset_config_description": "Reset configuration to default. This will log you out.", "advanced_reset_config_title": "Reset Configuration", diff --git a/ui/src/routes/devices.$id.settings.advanced.tsx b/ui/src/routes/devices.$id.settings.advanced.tsx index b3f4ab25f..735b2286a 100644 --- a/ui/src/routes/devices.$id.settings.advanced.tsx +++ b/ui/src/routes/devices.$id.settings.advanced.tsx @@ -32,6 +32,9 @@ export default function SettingsAdvancedRoute() { const [usbEmulationEnabled, setUsbEmulationEnabled] = useState(false); const [showLoopbackWarning, setShowLoopbackWarning] = useState(false); const [localLoopbackOnly, setLocalLoopbackOnly] = useState(false); + const [showTailscaleWarning, setShowTailscaleWarning] = useState(false); + const [localTailscaleOnly, setLocalTailscaleOnly] = useState(false); + const [tailscaleInstalled, setTailscaleInstalled] = useState(false); const [updateTarget, setUpdateTarget] = useState("app"); const [appVersion, setAppVersion] = useState(""); const [systemVersion, setSystemVersion] = useState(""); @@ -68,6 +71,17 @@ export default function SettingsAdvancedRoute() { setLocalLoopbackOnly(resp.result as boolean); }); + send("getLocalTailscaleOnly", {}, (resp: JsonRpcResponse) => { + if ("error" in resp) return; + setLocalTailscaleOnly(resp.result as boolean); + }); + + send("getTailscaleStatus", {}, (resp: JsonRpcResponse) => { + if ("error" in resp) return; + const status = resp.result as { installed: boolean; running: boolean }; + setTailscaleInstalled(status.installed && status.running); + }); + send("getDefaultLogLevel", {}, (resp: JsonRpcResponse) => { if ("error" in resp) return; setDefaultLogLevel(resp.result as string); @@ -197,6 +211,44 @@ export default function SettingsAdvancedRoute() { setShowLoopbackWarning(false); }, [applyLoopbackOnlyMode, setShowLoopbackWarning]); + const applyTailscaleOnlyMode = useCallback( + (enabled: boolean) => { + send("setLocalTailscaleOnly", { enabled }, (resp: JsonRpcResponse) => { + if ("error" in resp) { + notifications.error( + enabled + ? m.advanced_error_tailscale_enable({ error: resp.error.data || m.unknown_error() }) + : m.advanced_error_tailscale_disable({ error: resp.error.data || m.unknown_error() }), + ); + return; + } + setLocalTailscaleOnly(enabled); + if (enabled) { + notifications.success(m.advanced_success_tailscale_enabled()); + } else { + notifications.success(m.advanced_success_tailscale_disabled()); + } + }); + }, + [send, setLocalTailscaleOnly], + ); + + const handleTailscaleOnlyModeChange = useCallback( + (enabled: boolean) => { + if (enabled) { + setShowTailscaleWarning(true); + } else { + applyTailscaleOnlyMode(false); + } + }, + [applyTailscaleOnlyMode, setShowTailscaleWarning], + ); + + const confirmTailscaleModeEnable = useCallback(() => { + applyTailscaleOnlyMode(true); + setShowTailscaleWarning(false); + }, [applyTailscaleOnlyMode, setShowTailscaleWarning]); + const handleVersionUpdateError = useCallback((error?: JsonRpcError | string) => { notifications.error( m.advanced_error_version_update({ @@ -446,6 +498,18 @@ export default function SettingsAdvancedRoute() { /> + {tailscaleInstalled && ( + + handleTailscaleOnlyModeChange(e.target.checked)} + /> + + )} + + + { + setShowTailscaleWarning(false); + }} + title={m.advanced_tailscale_warning_title()} + description={ + <> +

{m.advanced_tailscale_warning_description()}

+

{m.advanced_tailscale_warning_before()}

+
    +
  • {m.advanced_tailscale_warning_ssh()}
  • +
  • {m.advanced_tailscale_warning_cloud()}
  • +
+ + } + variant="warning" + confirmText={m.advanced_tailscale_warning_confirm()} + onConfirm={confirmTailscaleModeEnable} + /> ); } diff --git a/web.go b/web.go index baa94ff48..c31695314 100644 --- a/web.go +++ b/web.go @@ -26,6 +26,7 @@ import ( "github.com/google/uuid" "github.com/jetkvm/kvm/internal/diagnostics" "github.com/jetkvm/kvm/internal/logging" + "github.com/jetkvm/kvm/internal/tailscale" "github.com/jetkvm/kvm/internal/supervisor" "github.com/pion/webrtc/v4" "github.com/prometheus/client_golang/prometheus" @@ -60,9 +61,10 @@ type ChangePasswordRequest struct { } type LocalDevice struct { - AuthMode *string `json:"authMode"` - DeviceID string `json:"deviceId"` - LoopbackOnly bool `json:"loopbackOnly"` + AuthMode *string `json:"authMode"` + DeviceID string `json:"deviceId"` + LoopbackOnly bool `json:"loopbackOnly"` + TailscaleOnly bool `json:"tailscaleOnly"` } type DeviceStatus struct { @@ -630,6 +632,27 @@ func basicAuthProtectedMiddleware(requireDeveloperMode bool) gin.HandlerFunc { func getBindAddress(listenPort int) string { // Determine the binding address based on the config + + // Tailscale-only mode takes highest priority: bind to the device's tailscale IP + if config.LocalTailscaleOnly { + status, err := tailscale.GetStatus(config.TailscaleControlURL, func(err error) { + tailscaleLogger.Warn().Err(err).Msg("failed to get tailscale status for bind address") + }) + if err == nil && status != nil && status.Running && status.Self != nil && len(status.Self.TailscaleIPs) > 0 { + // Prefer IPv4, fall back to IPv6 + for _, ip := range status.Self.TailscaleIPs { + if !strings.Contains(ip, ":") { + return fmt.Sprintf("%s:%d", ip, listenPort) + } + } + // No IPv4 found, use first IP (likely IPv6) + return fmt.Sprintf("[%s]:%d", status.Self.TailscaleIPs[0], listenPort) + } + // Tailscale status unavailable, fall back safely to loopback + logger.Warn().Msg("Tailscale-only mode enabled but tailscale status unavailable, falling back to localhost") + return fmt.Sprintf("127.0.0.1:%d", listenPort) + } + var bindAddress string useIPv4 := config.NetworkConfig.IPv4Mode.String != "disabled" useIPv6 := config.NetworkConfig.IPv6Mode.String != "disabled" @@ -660,7 +683,7 @@ func RunWebServer() { // Determine the binding address based on the config bindAddress := getBindAddress(80) // default port - logger.Info().Str("bindAddress", bindAddress).Bool("loopbackOnly", config.LocalLoopbackOnly).Msg("Starting web server") + logger.Info().Str("bindAddress", bindAddress).Bool("loopbackOnly", config.LocalLoopbackOnly).Bool("tailscaleOnly", config.LocalTailscaleOnly).Msg("Starting web server") if err := r.Run(bindAddress); err != nil { panic(err) } @@ -668,9 +691,10 @@ func RunWebServer() { func handleDevice(c *gin.Context) { response := LocalDevice{ - AuthMode: &config.LocalAuthMode, - DeviceID: GetDeviceID(), - LoopbackOnly: config.LocalLoopbackOnly, + AuthMode: &config.LocalAuthMode, + DeviceID: GetDeviceID(), + LoopbackOnly: config.LocalLoopbackOnly, + TailscaleOnly: config.LocalTailscaleOnly, } c.JSON(http.StatusOK, response) diff --git a/web_tls.go b/web_tls.go index 8376ba9a2..e9cc53fc6 100644 --- a/web_tls.go +++ b/web_tls.go @@ -172,7 +172,7 @@ func runWebSecureServer() { GetCertificate: getCertificate, }, } - websecureLogger.Info().Str("bindAddress", bindAddress).Bool("loopbackOnly", config.LocalLoopbackOnly).Msg("Starting websecure server") + websecureLogger.Info().Str("bindAddress", bindAddress).Bool("loopbackOnly", config.LocalLoopbackOnly).Bool("tailscaleOnly", config.LocalTailscaleOnly).Msg("Starting websecure server") go func() { for range stopTLS {