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() {
/>