feat: enhance network change handling and reboot logic

This commit is contained in:
Adam Shiervani 2025-10-14 16:18:33 +02:00
parent 895cd5cc39
commit 1cf7dd4be9
2 changed files with 90 additions and 24 deletions

View File

@ -3,6 +3,7 @@ package kvm
import ( import (
"context" "context"
"fmt" "fmt"
"reflect"
"github.com/jetkvm/kvm/internal/confparser" "github.com/jetkvm/kvm/internal/confparser"
"github.com/jetkvm/kvm/internal/mdns" "github.com/jetkvm/kvm/internal/mdns"
@ -170,6 +171,54 @@ func setHostname(nm *nmlite.NetworkManager, hostname, domain string) error {
return nm.SetHostname(hostname, domain) 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 { func rpcGetNetworkState() *types.RpcInterfaceState {
state, _ := networkManager.GetInterfaceState(NetIfName) state, _ := networkManager.GetInterfaceState(NetIfName)
return state.ToRpcInterfaceState() return state.ToRpcInterfaceState()
@ -189,9 +238,13 @@ func rpcSetNetworkSettings(settings RpcNetworkSettings) (*RpcNetworkSettings, er
l.Debug().Msg("setting new config") l.Debug().Msg("setting new config")
var rebootRequired bool // Check if reboot is needed
if netConfig.DHCPClient.String != config.NetworkConfig.DHCPClient.String { rebootRequired, suggestedIp := shouldRebootForNetworkChange(config.NetworkConfig, netConfig)
rebootRequired = true
// 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) _ = setHostname(networkManager, netConfig.Hostname.String, netConfig.Domain.String)
@ -238,5 +291,5 @@ func rpcToggleDHCPClient() error {
return err return err
} }
return rpcReboot(false) return rpcReboot(true)
} }

View File

@ -45,6 +45,7 @@ import {
ConnectionFailedOverlay, ConnectionFailedOverlay,
LoadingConnectionOverlay, LoadingConnectionOverlay,
PeerConnectionDisconnectedOverlay, PeerConnectionDisconnectedOverlay,
RebootingOverlay,
} from "@/components/VideoOverlay"; } from "@/components/VideoOverlay";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation"; import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
import { FeatureFlagProvider } from "@/providers/FeatureFlagProvider"; import { FeatureFlagProvider } from "@/providers/FeatureFlagProvider";
@ -122,7 +123,7 @@ export default function KvmIdRoute() {
const authMode = "authMode" in loaderResp ? loaderResp.authMode : null; const authMode = "authMode" in loaderResp ? loaderResp.authMode : null;
const params = useParams() as { id: string }; const params = useParams() as { id: string };
const { sidebarView, setSidebarView, disableVideoFocusTrap } = useUiStore(); const { sidebarView, setSidebarView, disableVideoFocusTrap, rebootState, setRebootState } = useUiStore();
const [queryParams, setQueryParams] = useSearchParams(); const [queryParams, setQueryParams] = useSearchParams();
const { const {
@ -241,7 +242,7 @@ export default function KvmIdRoute() {
{ {
heartbeat: true, heartbeat: true,
retryOnError: true, retryOnError: true,
reconnectAttempts: 15, reconnectAttempts: 2000,
reconnectInterval: 1000, reconnectInterval: 1000,
onReconnectStop: () => { onReconnectStop: () => {
console.debug("Reconnect stopped"); console.debug("Reconnect stopped");
@ -250,8 +251,7 @@ export default function KvmIdRoute() {
shouldReconnect(event) { shouldReconnect(event) {
console.debug("[Websocket] shouldReconnect", event); console.debug("[Websocket] shouldReconnect", event);
// TODO: Why true? return isLegacySignalingEnabled.current;
return true;
}, },
onClose(event) { onClose(event) {
@ -265,6 +265,16 @@ export default function KvmIdRoute() {
}, },
onOpen() { onOpen() {
console.debug("[Websocket] 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 => { onMessage: message => {
@ -340,10 +350,7 @@ export default function KvmIdRoute() {
peerConnection.addIceCandidate(candidate); peerConnection.addIceCandidate(candidate);
} }
}, },
}, }
// Don't even retry once we declare failure
!connectionFailed && isLegacySignalingEnabled.current === false,
); );
const sendWebRTCSignal = useCallback( const sendWebRTCSignal = useCallback(
@ -594,6 +601,8 @@ export default function KvmIdRoute() {
api.POST(`${CLOUD_API}/webrtc/turn_activity`, { api.POST(`${CLOUD_API}/webrtc/turn_activity`, {
bytesReceived: bytesReceivedDelta, bytesReceived: bytesReceivedDelta,
bytesSent: bytesSentDelta, bytesSent: bytesSentDelta,
}).catch(() => {
// we don't care about errors here, but we don't want unhandled promise rejections
}); });
}, 10000); }, 10000);
@ -666,6 +675,12 @@ export default function KvmIdRoute() {
window.location.href = currentUrl.toString(); 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); const { send } = useJsonRpc(onJsonRpcRequest);
@ -765,6 +780,14 @@ export default function KvmIdRoute() {
}, [appVersion, getLocalVersion]); }, [appVersion, getLocalVersion]);
const ConnectionStatusElement = useMemo(() => { const ConnectionStatusElement = useMemo(() => {
const isOtherSession = location.pathname.includes("other-session");
if (isOtherSession) return null;
// Rebooting takes priority over connection status
if (rebootState?.isRebooting) {
return <RebootingOverlay show={true} suggestedIp={rebootState.suggestedIp} />;
}
const hasConnectionFailed = const hasConnectionFailed =
connectionFailed || ["failed", "closed"].includes(peerConnectionState ?? ""); connectionFailed || ["failed", "closed"].includes(peerConnectionState ?? "");
@ -774,9 +797,6 @@ export default function KvmIdRoute() {
const isDisconnected = peerConnectionState === "disconnected"; const isDisconnected = peerConnectionState === "disconnected";
const isOtherSession = location.pathname.includes("other-session");
if (isOtherSession) return null;
if (peerConnectionState === "connected") return null; if (peerConnectionState === "connected") return null;
if (isDisconnected) { if (isDisconnected) {
return <PeerConnectionDisconnectedOverlay show={true} />; return <PeerConnectionDisconnectedOverlay show={true} />;
@ -792,14 +812,7 @@ export default function KvmIdRoute() {
} }
return null; return null;
}, [ }, [location.pathname, rebootState?.isRebooting, rebootState?.suggestedIp, connectionFailed, peerConnectionState, peerConnection, setupPeerConnection, loadingMessage]);
connectionFailed,
loadingMessage,
location.pathname,
peerConnection,
peerConnectionState,
setupPeerConnection,
]);
return ( return (
<FeatureFlagProvider appVersion={appVersion}> <FeatureFlagProvider appVersion={appVersion}>
@ -841,7 +854,7 @@ export default function KvmIdRoute() {
/> />
<div className="relative flex h-full w-full overflow-hidden"> <div className="relative flex h-full w-full overflow-hidden">
<WebRTCVideo /> <WebRTCVideo hasConnectionIssues={!!ConnectionStatusElement} />
<div <div
style={{ animationDuration: "500ms" }} style={{ animationDuration: "500ms" }}
className="animate-slideUpFade pointer-events-none absolute inset-0 flex items-center justify-center p-4" className="animate-slideUpFade pointer-events-none absolute inset-0 flex items-center justify-center p-4"