Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
21 changes: 21 additions & 0 deletions jsonrpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"}},
Expand Down
12 changes: 12 additions & 0 deletions ui/localization/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
85 changes: 85 additions & 0 deletions ui/src/routes/devices.$id.settings.advanced.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>("app");
const [appVersion, setAppVersion] = useState<string>("");
const [systemVersion, setSystemVersion] = useState<string>("");
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -446,6 +498,18 @@ export default function SettingsAdvancedRoute() {
/>
</SettingsItem>

{tailscaleInstalled && (
<SettingsItem
title={m.advanced_tailscale_only_title()}
description={m.advanced_tailscale_only_description()}
>
<Checkbox
checked={localTailscaleOnly}
onChange={e => handleTailscaleOnlyModeChange(e.target.checked)}
/>
</SettingsItem>
)}

<SettingsItem
title={m.advanced_troubleshooting_mode_title()}
description={m.advanced_troubleshooting_mode_description()}
Expand Down Expand Up @@ -569,6 +633,27 @@ export default function SettingsAdvancedRoute() {
confirmText={m.advanced_loopback_warning_confirm()}
onConfirm={confirmLoopbackModeEnable}
/>

<ConfirmDialog
open={showTailscaleWarning}
onClose={() => {
setShowTailscaleWarning(false);
}}
title={m.advanced_tailscale_warning_title()}
description={
<>
<p>{m.advanced_tailscale_warning_description()}</p>
<p>{m.advanced_tailscale_warning_before()}</p>
<ul className="list-disc space-y-1 pl-5 text-xs text-slate-700 dark:text-slate-300">
<li>{m.advanced_tailscale_warning_ssh()}</li>
<li>{m.advanced_tailscale_warning_cloud()}</li>
</ul>
</>
}
variant="warning"
confirmText={m.advanced_tailscale_warning_confirm()}
onConfirm={confirmTailscaleModeEnable}
/>
</div>
);
}
38 changes: 31 additions & 7 deletions web.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"github.com/google/uuid"
"github.com/jetkvm/kvm/internal/diagnostics"
"github.com/jetkvm/kvm/internal/logging"
"github.com/jetkvm/kvm/internal/tailscale"

Check failure on line 29 in web.go

View workflow job for this annotation

GitHub Actions / Go

File is not properly formatted (goimports)
"github.com/jetkvm/kvm/internal/supervisor"
"github.com/pion/webrtc/v4"
"github.com/prometheus/client_golang/prometheus"
Expand Down Expand Up @@ -60,9 +61,10 @@
}

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 {
Expand Down Expand Up @@ -630,6 +632,27 @@

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"
Expand Down Expand Up @@ -660,17 +683,18 @@
// 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)
}
}

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)
Expand Down
2 changes: 1 addition & 1 deletion web_tls.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading