diff --git a/network.go b/network.go index 8a7c2761..d839bab2 100644 --- a/network.go +++ b/network.go @@ -3,6 +3,7 @@ package kvm import ( "context" "fmt" + "reflect" "github.com/jetkvm/kvm/internal/confparser" "github.com/jetkvm/kvm/internal/mdns" @@ -170,6 +171,54 @@ func setHostname(nm *nmlite.NetworkManager, hostname, domain string) error { return nm.SetHostname(hostname, domain) } +func shouldRebootForNetworkChange(oldConfig, newConfig *types.NetworkConfig) (bool, *string) { + var rebootRequired bool + var suggestedIp *string + + oldDhcpClient := oldConfig.DHCPClient.String + + // DHCP client change always requires reboot + if newConfig.DHCPClient.String != oldDhcpClient { + rebootRequired = true + networkLogger.Info().Str("old", oldDhcpClient).Str("new", newConfig.DHCPClient.String).Msg("DHCP client changed, reboot required") + } + + // IPv4 mode change requires reboot when using udhcpc + if newConfig.IPv4Mode.String != oldConfig.IPv4Mode.String && oldDhcpClient == "udhcpc" { + rebootRequired = true + networkLogger.Info().Str("old", oldConfig.IPv4Mode.String).Str("new", newConfig.IPv4Mode.String).Msg("IPv4 mode changed with udhcpc, reboot required") + } + + // IPv4 static config changes require reboot + if newConfig.IPv4Static != nil && oldConfig.IPv4Static != nil { + if newConfig.IPv4Static.Address.String != oldConfig.IPv4Static.Address.String { + rebootRequired = true + suggestedIp = &newConfig.IPv4Static.Address.String + networkLogger.Info().Str("old", oldConfig.IPv4Static.Address.String).Str("new", newConfig.IPv4Static.Address.String).Msg("IPv4 address changed, reboot required") + } + if newConfig.IPv4Static.Netmask.String != oldConfig.IPv4Static.Netmask.String { + rebootRequired = true + networkLogger.Info().Str("old", oldConfig.IPv4Static.Netmask.String).Str("new", newConfig.IPv4Static.Netmask.String).Msg("IPv4 netmask changed, reboot required") + } + if newConfig.IPv4Static.Gateway.String != oldConfig.IPv4Static.Gateway.String { + rebootRequired = true + networkLogger.Info().Str("old", oldConfig.IPv4Static.Gateway.String).Str("new", newConfig.IPv4Static.Gateway.String).Msg("IPv4 gateway changed, reboot required") + } + if !reflect.DeepEqual(newConfig.IPv4Static.DNS, oldConfig.IPv4Static.DNS) { + rebootRequired = true + networkLogger.Info().Strs("old", oldConfig.IPv4Static.DNS).Strs("new", newConfig.IPv4Static.DNS).Msg("IPv4 DNS changed, reboot required") + } + } + + // IPv6 mode change requires reboot when using udhcpc + if newConfig.IPv6Mode.String != oldConfig.IPv6Mode.String && oldDhcpClient == "udhcpc" { + rebootRequired = true + networkLogger.Info().Str("old", oldConfig.IPv6Mode.String).Str("new", newConfig.IPv6Mode.String).Msg("IPv6 mode changed with udhcpc, reboot required") + } + + return rebootRequired, suggestedIp +} + func rpcGetNetworkState() *types.RpcInterfaceState { state, _ := networkManager.GetInterfaceState(NetIfName) return state.ToRpcInterfaceState() @@ -189,9 +238,13 @@ func rpcSetNetworkSettings(settings RpcNetworkSettings) (*RpcNetworkSettings, er l.Debug().Msg("setting new config") - var rebootRequired bool - if netConfig.DHCPClient.String != config.NetworkConfig.DHCPClient.String { - rebootRequired = true + // Check if reboot is needed + rebootRequired, suggestedIp := shouldRebootForNetworkChange(config.NetworkConfig, netConfig) + + // If reboot required, send willReboot event before applying network config + if rebootRequired { + l.Info().Msg("Sending willReboot event before applying network config") + writeJSONRPCEvent("willReboot", suggestedIp, currentSession) } _ = setHostname(networkManager, netConfig.Hostname.String, netConfig.Domain.String) @@ -238,5 +291,5 @@ func rpcToggleDHCPClient() error { return err } - return rpcReboot(false) + return rpcReboot(true) } diff --git a/ui/src/routes/devices.$id.tsx b/ui/src/routes/devices.$id.tsx index 05bf8e0a..eedbece2 100644 --- a/ui/src/routes/devices.$id.tsx +++ b/ui/src/routes/devices.$id.tsx @@ -45,6 +45,7 @@ import { ConnectionFailedOverlay, LoadingConnectionOverlay, PeerConnectionDisconnectedOverlay, + RebootingOverlay, } from "@/components/VideoOverlay"; import { useDeviceUiNavigation } from "@/hooks/useAppNavigation"; import { FeatureFlagProvider } from "@/providers/FeatureFlagProvider"; @@ -122,7 +123,7 @@ export default function KvmIdRoute() { const authMode = "authMode" in loaderResp ? loaderResp.authMode : null; const params = useParams() as { id: string }; - const { sidebarView, setSidebarView, disableVideoFocusTrap } = useUiStore(); + const { sidebarView, setSidebarView, disableVideoFocusTrap, rebootState, setRebootState } = useUiStore(); const [queryParams, setQueryParams] = useSearchParams(); const { @@ -241,7 +242,7 @@ export default function KvmIdRoute() { { heartbeat: true, retryOnError: true, - reconnectAttempts: 15, + reconnectAttempts: 2000, reconnectInterval: 1000, onReconnectStop: () => { console.debug("Reconnect stopped"); @@ -250,8 +251,7 @@ export default function KvmIdRoute() { shouldReconnect(event) { console.debug("[Websocket] shouldReconnect", event); - // TODO: Why true? - return true; + return isLegacySignalingEnabled.current; }, onClose(event) { @@ -265,6 +265,16 @@ export default function KvmIdRoute() { }, onOpen() { console.debug("[Websocket] onOpen"); + // We want to clear the reboot state when the websocket connection is opened + // Currently the flow is: + // 1. User clicks reboot + // 2. Device sends event 'willReboot' + // 3. We set the reboot state + // 4. Reboot modal is shown + // 5. WS tries to reconnect + // 6. WS reconnects + // 7. This function is called and now we clear the reboot state + setRebootState({ isRebooting: false, suggestedIp: null }); }, onMessage: message => { @@ -340,10 +350,7 @@ export default function KvmIdRoute() { peerConnection.addIceCandidate(candidate); } }, - }, - - // Don't even retry once we declare failure - !connectionFailed && isLegacySignalingEnabled.current === false, + } ); const sendWebRTCSignal = useCallback( @@ -594,6 +601,8 @@ export default function KvmIdRoute() { api.POST(`${CLOUD_API}/webrtc/turn_activity`, { bytesReceived: bytesReceivedDelta, bytesSent: bytesSentDelta, + }).catch(() => { + // we don't care about errors here, but we don't want unhandled promise rejections }); }, 10000); @@ -666,6 +675,12 @@ export default function KvmIdRoute() { window.location.href = currentUrl.toString(); } } + + if (resp.method === "willReboot") { + const suggestedIp = resp.params as unknown as string | null; + setRebootState({ isRebooting: true, suggestedIp }); + navigateTo("/"); + } } const { send } = useJsonRpc(onJsonRpcRequest); @@ -765,6 +780,14 @@ export default function KvmIdRoute() { }, [appVersion, getLocalVersion]); const ConnectionStatusElement = useMemo(() => { + const isOtherSession = location.pathname.includes("other-session"); + if (isOtherSession) return null; + + // Rebooting takes priority over connection status + if (rebootState?.isRebooting) { + return ; + } + const hasConnectionFailed = connectionFailed || ["failed", "closed"].includes(peerConnectionState ?? ""); @@ -774,9 +797,6 @@ export default function KvmIdRoute() { const isDisconnected = peerConnectionState === "disconnected"; - const isOtherSession = location.pathname.includes("other-session"); - - if (isOtherSession) return null; if (peerConnectionState === "connected") return null; if (isDisconnected) { return ; @@ -792,14 +812,7 @@ export default function KvmIdRoute() { } return null; - }, [ - connectionFailed, - loadingMessage, - location.pathname, - peerConnection, - peerConnectionState, - setupPeerConnection, - ]); + }, [location.pathname, rebootState?.isRebooting, rebootState?.suggestedIp, connectionFailed, peerConnectionState, peerConnection, setupPeerConnection, loadingMessage]); return ( @@ -841,7 +854,7 @@ export default function KvmIdRoute() { />
- +