Compare commits

...

6 Commits

Author SHA1 Message Date
Aveline 406c27fffc
Merge 1097deeaf8 into 5f15d8b2f6 2025-11-06 11:12:25 +01:00
Adam Shiervani 5f15d8b2f6
refactor: More robust handling of jsonrpc calls (#915)
Co-authored-by: Marc Brooks <IDisposable@gmail.com>
2025-11-06 11:12:19 +01:00
Siyuan 1097deeaf8 fix: clean-up cancel function 2025-11-05 15:23:53 +00:00
Siyuan d6d113e253 fix: remoteIPaddress can't be null 2025-11-05 15:21:14 +00:00
Siyuan 7d2fe79993 feat: add public IP card to settings network page 2025-11-05 15:13:15 +00:00
Siyuan 8a54d4c645 feat: add remote IP address to connection stats sidebar 2025-11-05 15:03:29 +00:00
13 changed files with 695 additions and 80 deletions

View File

@ -10,5 +10,6 @@
] ]
}, },
"git.ignoreLimitWarning": true, "git.ignoreLimitWarning": true,
"cmake.sourceDirectory": "/workspaces/kvm-static-ip/internal/native/cgo" "cmake.sourceDirectory": "/workspaces/kvm-static-ip/internal/native/cgo",
"cmake.ignoreCMakeListsMissing": true
} }

View File

@ -932,6 +932,10 @@ func rpcSetCloudUrl(apiUrl string, appUrl string) error {
disconnectCloud(fmt.Errorf("cloud url changed from %s to %s", currentCloudURL, apiUrl)) disconnectCloud(fmt.Errorf("cloud url changed from %s to %s", currentCloudURL, apiUrl))
} }
if publicIPState != nil {
publicIPState.SetCloudflareEndpoint(apiUrl)
}
if err := SaveConfig(); err != nil { if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err) return fmt.Errorf("failed to save config: %w", err)
} }
@ -1248,4 +1252,6 @@ var rpcHandlers = map[string]RPCHandler{
"setKeyboardMacros": {Func: setKeyboardMacros, Params: []string{"params"}}, "setKeyboardMacros": {Func: setKeyboardMacros, Params: []string{"params"}},
"getLocalLoopbackOnly": {Func: rpcGetLocalLoopbackOnly}, "getLocalLoopbackOnly": {Func: rpcGetLocalLoopbackOnly},
"setLocalLoopbackOnly": {Func: rpcSetLocalLoopbackOnly, Params: []string{"enabled"}}, "setLocalLoopbackOnly": {Func: rpcSetLocalLoopbackOnly, Params: []string{"enabled"}},
"getPublicIPAddresses": {Func: rpcGetPublicIPAddresses, Params: []string{"refresh"}},
"checkPublicIPAddresses": {Func: rpcCheckPublicIPAddresses},
} }

View File

@ -126,6 +126,7 @@ func Main() {
// As websocket client already checks if the cloud token is set, we can start it here. // As websocket client already checks if the cloud token is set, we can start it here.
go RunWebsocketClient() go RunWebsocketClient()
initPublicIPState()
initSerialPort() initSerialPort()
sigs := make(chan os.Signal, 1) sigs := make(chan os.Signal, 1)

View File

@ -3,12 +3,17 @@ package kvm
import ( import (
"context" "context"
"fmt" "fmt"
"net"
"net/http"
"reflect" "reflect"
"time"
"github.com/jetkvm/kvm/internal/confparser" "github.com/jetkvm/kvm/internal/confparser"
"github.com/jetkvm/kvm/internal/mdns" "github.com/jetkvm/kvm/internal/mdns"
"github.com/jetkvm/kvm/internal/network/types" "github.com/jetkvm/kvm/internal/network/types"
"github.com/jetkvm/kvm/pkg/myip"
"github.com/jetkvm/kvm/pkg/nmlite" "github.com/jetkvm/kvm/pkg/nmlite"
"github.com/jetkvm/kvm/pkg/nmlite/link"
) )
const ( const (
@ -17,6 +22,7 @@ const (
var ( var (
networkManager *nmlite.NetworkManager networkManager *nmlite.NetworkManager
publicIPState *myip.PublicIPState
) )
type RpcNetworkSettings struct { type RpcNetworkSettings struct {
@ -115,6 +121,14 @@ func networkStateChanged(_ string, state types.InterfaceState) {
if state.Online { if state.Online {
networkLogger.Info().Msg("network state changed to online, triggering time sync") networkLogger.Info().Msg("network state changed to online, triggering time sync")
triggerTimeSyncOnNetworkStateChange() triggerTimeSyncOnNetworkStateChange()
if publicIPState != nil {
publicIPState.SetIPv4AndIPv6(state.IPv4Ready, state.IPv6Ready)
}
} else {
if publicIPState != nil {
publicIPState.SetIPv4AndIPv6(false, false)
}
} }
// always restart mDNS when the network state changes // always restart mDNS when the network state changes
@ -164,6 +178,40 @@ func initNetwork() error {
return nil return nil
} }
func initPublicIPState() {
// the feature will be only enabled if the cloud has been adopted
// due to privacy reasons
// but it will be initialized anyway to avoid nil pointer dereferences
ps := myip.NewPublicIPState(&myip.PublicIPStateConfig{
Logger: networkLogger,
CloudflareEndpoint: config.CloudURL,
APIEndpoint: "",
IPv4: false,
IPv6: false,
HttpClientGetter: func(family int) *http.Client {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = config.NetworkConfig.GetTransportProxyFunc()
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
netType := network
switch family {
case link.AfInet:
netType = "tcp4"
case link.AfInet6:
netType = "tcp6"
}
return (&net.Dialer{}).DialContext(ctx, netType, addr)
}
return &http.Client{
Transport: transport,
Timeout: 30 * time.Second,
}
},
})
publicIPState = ps
}
func setHostname(nm *nmlite.NetworkManager, hostname, domain string) error { func setHostname(nm *nmlite.NetworkManager, hostname, domain string) error {
if nm == nil { if nm == nil {
return nil return nil
@ -312,3 +360,25 @@ func rpcToggleDHCPClient() error {
return rpcReboot(true) return rpcReboot(true)
} }
func rpcGetPublicIPAddresses(refresh bool) ([]myip.PublicIP, error) {
if publicIPState == nil {
return nil, fmt.Errorf("public IP state not initialized")
}
if refresh {
if err := publicIPState.ForceUpdate(); err != nil {
return nil, err
}
}
return publicIPState.GetAddresses(), nil
}
func rpcCheckPublicIPAddresses() error {
if publicIPState == nil {
return fmt.Errorf("public IP state not initialized")
}
return publicIPState.ForceUpdate()
}

142
pkg/myip/check.go Normal file
View File

@ -0,0 +1,142 @@
package myip
import (
"context"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/jetkvm/kvm/pkg/nmlite/link"
)
func (ps *PublicIPState) request(ctx context.Context, url string, family int) ([]byte, error) {
client := ps.httpClient(family)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, fmt.Errorf("error creating request: %w", err)
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("error sending request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("error reading response body: %w", err)
}
return body, err
}
// checkCloudflare uses cdn-cgi/trace to get the public IP address
func (ps *PublicIPState) checkCloudflare(ctx context.Context, family int) (*PublicIP, error) {
u, err := url.JoinPath(ps.cloudflareEndpoint, "/cdn-cgi/trace")
if err != nil {
return nil, fmt.Errorf("error joining path: %w", err)
}
body, err := ps.request(ctx, u, family)
if err != nil {
return nil, err
}
for line := range strings.SplitSeq(string(body), "\n") {
key, value, ok := strings.Cut(line, "=")
if !ok || key != "ip" {
continue
}
return &PublicIP{
IPAddress: net.ParseIP(value),
LastUpdated: time.Now(),
}, nil
}
return nil, fmt.Errorf("no IP address found")
}
// checkAPI uses the API endpoint to get the public IP address
func (ps *PublicIPState) checkAPI(_ context.Context, _ int) (*PublicIP, error) {
return nil, fmt.Errorf("not implemented")
}
// checkIPs checks both IPv4 and IPv6 public IP addresses in parallel
// and updates the IPAddresses slice with the results
func (ps *PublicIPState) checkIPs(ctx context.Context, checkIPv4, checkIPv6 bool) error {
var wg sync.WaitGroup
var mu sync.Mutex
var ips []PublicIP
var errors []error
checkFamily := func(family int, familyName string) {
wg.Add(1)
go func(f int, name string) {
defer wg.Done()
ip, err := ps.checkIPForFamily(ctx, f)
mu.Lock()
defer mu.Unlock()
if err != nil {
errors = append(errors, fmt.Errorf("%s check failed: %w", name, err))
return
}
if ip != nil {
ips = append(ips, *ip)
}
}(family, familyName)
}
if checkIPv4 {
checkFamily(link.AfInet, "IPv4")
}
if checkIPv6 {
checkFamily(link.AfInet6, "IPv6")
}
wg.Wait()
if len(ips) > 0 {
ps.mu.Lock()
defer ps.mu.Unlock()
ps.addresses = ips
ps.lastUpdated = time.Now()
}
if len(errors) > 0 && len(ips) == 0 {
return errors[0]
}
return nil
}
func (ps *PublicIPState) checkIPForFamily(ctx context.Context, family int) (*PublicIP, error) {
if ps.apiEndpoint != "" {
ip, err := ps.checkAPI(ctx, family)
if err == nil && ip != nil {
return ip, nil
}
}
if ps.cloudflareEndpoint != "" {
ip, err := ps.checkCloudflare(ctx, family)
if err == nil && ip != nil {
return ip, nil
}
}
return nil, fmt.Errorf("all IP check methods failed for family %d", family)
}

212
pkg/myip/ip.go Normal file
View File

@ -0,0 +1,212 @@
package myip
import (
"context"
"fmt"
"net"
"net/http"
"net/url"
"sync"
"time"
"github.com/jetkvm/kvm/internal/logging"
"github.com/rs/zerolog"
)
type PublicIP struct {
IPAddress net.IP `json:"ip"`
LastUpdated time.Time `json:"last_updated"`
}
type HttpClientGetter func(family int) *http.Client
type PublicIPState struct {
addresses []PublicIP
lastUpdated time.Time
cloudflareEndpoint string // cdn-cgi/trace domain
apiEndpoint string // api endpoint
ipv4 bool
ipv6 bool
httpClient HttpClientGetter
logger *zerolog.Logger
timer *time.Timer
ctx context.Context
cancel context.CancelFunc
mu sync.Mutex
}
type PublicIPStateConfig struct {
CloudflareEndpoint string
APIEndpoint string
IPv4 bool
IPv6 bool
HttpClientGetter HttpClientGetter
Logger *zerolog.Logger
}
func stripURLPath(s string) string {
parsed, err := url.Parse(s)
if err != nil {
return ""
}
scheme := parsed.Scheme
if scheme != "http" && scheme != "https" {
scheme = "https"
}
return fmt.Sprintf("%s://%s", scheme, parsed.Host)
}
// NewPublicIPState creates a new PublicIPState
func NewPublicIPState(config *PublicIPStateConfig) *PublicIPState {
if config.Logger == nil {
config.Logger = logging.GetSubsystemLogger("publicip")
}
ctx, cancel := context.WithCancel(context.Background())
ps := &PublicIPState{
addresses: make([]PublicIP, 0),
lastUpdated: time.Now(),
cloudflareEndpoint: stripURLPath(config.CloudflareEndpoint),
apiEndpoint: config.APIEndpoint,
ipv4: config.IPv4,
ipv6: config.IPv6,
httpClient: config.HttpClientGetter,
ctx: ctx,
cancel: cancel,
logger: config.Logger,
}
// Start the timer automatically
ps.Start()
return ps
}
// SetFamily sets if we need to track IPv4 and IPv6 public IP addresses
func (ps *PublicIPState) SetIPv4AndIPv6(ipv4, ipv6 bool) {
ps.mu.Lock()
defer ps.mu.Unlock()
ps.ipv4 = ipv4
ps.ipv6 = ipv6
}
// SetIPv4 sets if we need to track IPv4 public IP addresses
func (ps *PublicIPState) SetIPv4(ipv4 bool) {
ps.mu.Lock()
defer ps.mu.Unlock()
ps.ipv4 = ipv4
}
// SetIPv6 sets if we need to track IPv6 public IP addresses
func (ps *PublicIPState) SetIPv6(ipv6 bool) {
ps.mu.Lock()
defer ps.mu.Unlock()
ps.ipv6 = ipv6
}
// SetCloudflareEndpoint sets the Cloudflare endpoint
func (ps *PublicIPState) SetCloudflareEndpoint(endpoint string) {
ps.mu.Lock()
defer ps.mu.Unlock()
ps.cloudflareEndpoint = stripURLPath(endpoint)
}
// SetAPIEndpoint sets the API endpoint
func (ps *PublicIPState) SetAPIEndpoint(endpoint string) {
ps.mu.Lock()
defer ps.mu.Unlock()
ps.apiEndpoint = endpoint
}
// GetAddresses returns the public IP addresses
func (ps *PublicIPState) GetAddresses() []PublicIP {
ps.mu.Lock()
defer ps.mu.Unlock()
return ps.addresses
}
// Start starts the timer loop to check public IP addresses periodically
func (ps *PublicIPState) Start() {
ps.mu.Lock()
defer ps.mu.Unlock()
// Stop any existing timer
if ps.timer != nil {
ps.timer.Stop()
}
if ps.cancel != nil {
ps.cancel()
}
// Create new context and cancel function
ps.ctx, ps.cancel = context.WithCancel(context.Background())
// Start the timer loop in a goroutine
go ps.timerLoop(ps.ctx)
}
// Stop stops the timer loop
func (ps *PublicIPState) Stop() {
ps.mu.Lock()
defer ps.mu.Unlock()
if ps.cancel != nil {
ps.cancel()
ps.cancel = nil
}
if ps.timer != nil {
ps.timer.Stop()
ps.timer = nil
}
}
// ForceUpdate forces an update of the public IP addresses
func (ps *PublicIPState) ForceUpdate() error {
return ps.checkIPs(context.Background(), true, true)
}
// timerLoop runs the periodic IP check loop
func (ps *PublicIPState) timerLoop(ctx context.Context) {
timer := time.NewTimer(5 * time.Minute)
defer timer.Stop()
// Store timer reference for Stop() to access
ps.mu.Lock()
ps.timer = timer
checkIPv4 := ps.ipv4
checkIPv6 := ps.ipv6
ps.mu.Unlock()
// Perform initial check immediately
checkIPs := func() {
if err := ps.checkIPs(ctx, checkIPv4, checkIPv6); err != nil {
ps.logger.Error().Err(err).Msg("failed to check public IP addresses")
}
}
checkIPs()
for {
select {
case <-timer.C:
// Perform the check
checkIPs()
// Reset the timer for the next check
timer.Reset(5 * time.Minute)
case <-ctx.Done():
// Timer was stopped
return
}
}
}

View File

@ -897,5 +897,11 @@
"wake_on_lan_invalid_mac": "Invalid MAC address", "wake_on_lan_invalid_mac": "Invalid MAC address",
"wake_on_lan_magic_sent_success": "Magic Packet sent successfully", "wake_on_lan_magic_sent_success": "Magic Packet sent successfully",
"welcome_to_jetkvm": "Welcome to JetKVM", "welcome_to_jetkvm": "Welcome to JetKVM",
"welcome_to_jetkvm_description": "Control any computer remotely" "welcome_to_jetkvm_description": "Control any computer remotely","connection_stats_remote_ip_address": "Remote IP Address",
"connection_stats_remote_ip_address_description": "The IP address of the remote device.",
"connection_stats_remote_ip_address_copy_error": "Failed to copy remote IP address",
"connection_stats_remote_ip_address_copy_success": "Remote IP address { ip } copied to clipboard",
"public_ip_card_header": "Public IP addresses",
"public_ip_card_refresh": "Refresh",
"public_ip_card_refresh_error": "Failed to refresh public IP addresses: {error}"
} }

View File

@ -0,0 +1,86 @@
import { LuRefreshCcw } from "react-icons/lu";
import { useCallback, useEffect, useState } from "react";
import { Button } from "@components/Button";
import { GridCard } from "@components/Card";
import { PublicIP } from "@hooks/stores";
import { m } from "@localizations/messages.js";
import { JsonRpcResponse, useJsonRpc } from "@hooks/useJsonRpc";
import notifications from "@/notifications";
export default function PublicIPCard() {
const { send } = useJsonRpc();
const [publicIPs, setPublicIPs] = useState<PublicIP[]>([]);
const refreshPublicIPs = useCallback(() => {
send("getPublicIPAddresses", { refresh: true }, (resp: JsonRpcResponse) => {
setPublicIPs([]);
if ("error" in resp) {
notifications.error(m.public_ip_card_refresh_error({ error: resp.error.data || m.unknown_error() }));
return;
}
const publicIPs = resp.result as PublicIP[];
setPublicIPs(publicIPs.sort(({ ip: aIp }, { ip: bIp }) => {
const aIsIPv6 = aIp.includes(":");
const bIsIPv6 = bIp.includes(":");
if (aIsIPv6 && !bIsIPv6) return 1;
if (!aIsIPv6 && bIsIPv6) return -1;
return aIp.localeCompare(bIp);
}));
});
}, [send, setPublicIPs]);
useEffect(() => {
refreshPublicIPs();
}, [refreshPublicIPs]);
return (
<GridCard>
<div className="animate-fadeIn p-4 text-black opacity-0 animation-duration-500 dark:text-white">
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-base font-bold text-slate-900 dark:text-white">
{m.public_ip_card_header()}
</h3>
<div>
<Button
size="XS"
theme="light"
type="button"
className="text-red-500"
text={m.public_ip_card_refresh()}
LeadingIcon={LuRefreshCcw}
onClick={refreshPublicIPs}
/>
</div>
</div>
{publicIPs.length === 0 ? (
<div>
<div className="space-y-4">
<div className="animate-pulse space-y-2">
<div className="h-4 w-1/4 rounded bg-slate-200 dark:bg-slate-700" />
<div className="h-4 w-1/3 rounded bg-slate-200 dark:bg-slate-700" />
<div className="h-4 w-1/2 rounded bg-slate-200 dark:bg-slate-700" />
</div>
</div>
</div>
) : (
<div className="flex gap-x-6 gap-y-2">
<div className="flex-1 space-y-2">
{publicIPs?.map(ip => (
<div key={ip.ip} className="flex justify-between border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm font-medium">
{ip.ip}
</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
</GridCard>
);
}

View File

@ -1,4 +1,6 @@
import { useInterval } from "usehooks-ts"; import { useInterval } from "usehooks-ts";
import { LuCopy } from "react-icons/lu";
import { useState } from "react";
import { m } from "@localizations/messages.js"; import { m } from "@localizations/messages.js";
import { useRTCStore, useUiStore } from "@hooks/stores"; import { useRTCStore, useUiStore } from "@hooks/stores";
@ -6,6 +8,10 @@ import { createChartArray, Metric } from "@components/Metric";
import { SettingsSectionHeader } from "@components/SettingsSectionHeader"; import { SettingsSectionHeader } from "@components/SettingsSectionHeader";
import SidebarHeader from "@components/SidebarHeader"; import SidebarHeader from "@components/SidebarHeader";
import { someIterable } from "@/utils"; import { someIterable } from "@/utils";
import { GridCard } from "@components/Card";
import { Button } from "@components/Button";
import { useCopyToClipboard } from "@components/useCopyToClipBoard";
import notifications from "@/notifications";
export default function ConnectionStatsSidebar() { export default function ConnectionStatsSidebar() {
const { sidebarView, setSidebarView } = useUiStore(); const { sidebarView, setSidebarView } = useUiStore();
@ -21,6 +27,8 @@ export default function ConnectionStatsSidebar() {
appendDiskDataChannelStats, appendDiskDataChannelStats,
} = useRTCStore(); } = useRTCStore();
const [remoteIPAddress, setRemoteIPAddress] = useState<string | null>(null);
useInterval(function collectWebRTCStats() { useInterval(function collectWebRTCStats() {
(async () => { (async () => {
if (!mediaStream) return; if (!mediaStream) return;
@ -49,6 +57,7 @@ export default function ConnectionStatsSidebar() {
} else if (report.type === "remote-candidate") { } else if (report.type === "remote-candidate") {
if (successfulRemoteCandidateId === report.id) { if (successfulRemoteCandidateId === report.id) {
appendRemoteCandidateStats(report); appendRemoteCandidateStats(report);
setRemoteIPAddress(report.address);
} }
} else if (report.type === "data-channel" && report.label === "disk") { } else if (report.type === "data-channel" && report.label === "disk") {
appendDiskDataChannelStats(report); appendDiskDataChannelStats(report);
@ -93,6 +102,8 @@ export default function ConnectionStatsSidebar() {
return { date: d.date, metric: valueMs }; return { date: d.date, metric: valueMs };
}); });
const { copy } = useCopyToClipboard();
return ( return (
<div className="grid h-full grid-rows-(--grid-headerBody) shadow-xs"> <div className="grid h-full grid-rows-(--grid-headerBody) shadow-xs">
<SidebarHeader title={m.connection_stats_sidebar()} setSidebarView={setSidebarView} /> <SidebarHeader title={m.connection_stats_sidebar()} setSidebarView={setSidebarView} />
@ -106,6 +117,27 @@ export default function ConnectionStatsSidebar() {
title={m.connection_stats_connection()} title={m.connection_stats_connection()}
description={m.connection_stats_connection_description()} description={m.connection_stats_connection_description()}
/> />
{remoteIPAddress && (
<div className="flex items-center justify-between">
<div className="text-sm text-slate-600 dark:text-slate-400">
{m.connection_stats_remote_ip_address()}
</div>
<div className="flex items-center">
<GridCard cardClassName="rounded-r-none">
<div className="h-[34px] flex items-center text-xs select-all text-black font-mono dark:text-white px-3 ">
{remoteIPAddress}
</div>
</GridCard>
<Button className="rounded-l-none border-l-slate-800/30 dark:border-slate-300/20" size="SM" type="button" theme="light" LeadingIcon={LuCopy} onClick={async () => {
if (await copy(remoteIPAddress)) {
notifications.success((m.connection_stats_remote_ip_address_copy_success({ ip: remoteIPAddress })));
} else {
notifications.error(m.connection_stats_remote_ip_address_copy_error());
}
}} />
</div>
</div>
)}
<Metric <Metric
title={m.connection_stats_round_trip_time()} title={m.connection_stats_round_trip_time()}
description={m.connection_stats_round_trip_time_description()} description={m.connection_stats_round_trip_time_description()}

View File

@ -116,7 +116,7 @@ export interface RTCState {
peerConnection: RTCPeerConnection | null; peerConnection: RTCPeerConnection | null;
setPeerConnection: (pc: RTCState["peerConnection"]) => void; setPeerConnection: (pc: RTCState["peerConnection"]) => void;
setRpcDataChannel: (channel: RTCDataChannel) => void; setRpcDataChannel: (channel: RTCDataChannel | null) => void;
rpcDataChannel: RTCDataChannel | null; rpcDataChannel: RTCDataChannel | null;
hidRpcDisabled: boolean; hidRpcDisabled: boolean;
@ -178,41 +178,42 @@ export const useRTCStore = create<RTCState>(set => ({
setPeerConnection: (pc: RTCState["peerConnection"]) => set({ peerConnection: pc }), setPeerConnection: (pc: RTCState["peerConnection"]) => set({ peerConnection: pc }),
rpcDataChannel: null, rpcDataChannel: null,
setRpcDataChannel: (channel: RTCDataChannel) => set({ rpcDataChannel: channel }), setRpcDataChannel: channel => set({ rpcDataChannel: channel }),
hidRpcDisabled: false, hidRpcDisabled: false,
setHidRpcDisabled: (disabled: boolean) => set({ hidRpcDisabled: disabled }), setHidRpcDisabled: disabled => set({ hidRpcDisabled: disabled }),
rpcHidProtocolVersion: null, rpcHidProtocolVersion: null,
setRpcHidProtocolVersion: (version: number | null) => set({ rpcHidProtocolVersion: version }), setRpcHidProtocolVersion: version => set({ rpcHidProtocolVersion: version }),
rpcHidChannel: null, rpcHidChannel: null,
setRpcHidChannel: (channel: RTCDataChannel) => set({ rpcHidChannel: channel }), setRpcHidChannel: channel => set({ rpcHidChannel: channel }),
rpcHidUnreliableChannel: null, rpcHidUnreliableChannel: null,
setRpcHidUnreliableChannel: (channel: RTCDataChannel) => set({ rpcHidUnreliableChannel: channel }), setRpcHidUnreliableChannel: channel => set({ rpcHidUnreliableChannel: channel }),
rpcHidUnreliableNonOrderedChannel: null, rpcHidUnreliableNonOrderedChannel: null,
setRpcHidUnreliableNonOrderedChannel: (channel: RTCDataChannel) => set({ rpcHidUnreliableNonOrderedChannel: channel }), setRpcHidUnreliableNonOrderedChannel: channel =>
set({ rpcHidUnreliableNonOrderedChannel: channel }),
transceiver: null, transceiver: null,
setTransceiver: (transceiver: RTCRtpTransceiver) => set({ transceiver }), setTransceiver: transceiver => set({ transceiver }),
peerConnectionState: null, peerConnectionState: null,
setPeerConnectionState: (state: RTCPeerConnectionState) => set({ peerConnectionState: state }), setPeerConnectionState: state => set({ peerConnectionState: state }),
mediaStream: null, mediaStream: null,
setMediaStream: (stream: MediaStream) => set({ mediaStream: stream }), setMediaStream: stream => set({ mediaStream: stream }),
videoStreamStats: null, videoStreamStats: null,
appendVideoStreamStats: (stats: RTCInboundRtpStreamStats) => set({ videoStreamStats: stats }), appendVideoStreamStats: stats => set({ videoStreamStats: stats }),
videoStreamStatsHistory: new Map(), videoStreamStatsHistory: new Map(),
isTurnServerInUse: false, isTurnServerInUse: false,
setTurnServerInUse: (inUse: boolean) => set({ isTurnServerInUse: inUse }), setTurnServerInUse: inUse => set({ isTurnServerInUse: inUse }),
inboundRtpStats: new Map(), inboundRtpStats: new Map(),
appendInboundRtpStats: (stats: RTCInboundRtpStreamStats) => { appendInboundRtpStats: stats => {
set(prevState => ({ set(prevState => ({
inboundRtpStats: appendStatToMap(stats, prevState.inboundRtpStats), inboundRtpStats: appendStatToMap(stats, prevState.inboundRtpStats),
})); }));
@ -220,7 +221,7 @@ export const useRTCStore = create<RTCState>(set => ({
clearInboundRtpStats: () => set({ inboundRtpStats: new Map() }), clearInboundRtpStats: () => set({ inboundRtpStats: new Map() }),
candidatePairStats: new Map(), candidatePairStats: new Map(),
appendCandidatePairStats: (stats: RTCIceCandidatePairStats) => { appendCandidatePairStats: stats => {
set(prevState => ({ set(prevState => ({
candidatePairStats: appendStatToMap(stats, prevState.candidatePairStats), candidatePairStats: appendStatToMap(stats, prevState.candidatePairStats),
})); }));
@ -228,21 +229,21 @@ export const useRTCStore = create<RTCState>(set => ({
clearCandidatePairStats: () => set({ candidatePairStats: new Map() }), clearCandidatePairStats: () => set({ candidatePairStats: new Map() }),
localCandidateStats: new Map(), localCandidateStats: new Map(),
appendLocalCandidateStats: (stats: RTCIceCandidateStats) => { appendLocalCandidateStats: stats => {
set(prevState => ({ set(prevState => ({
localCandidateStats: appendStatToMap(stats, prevState.localCandidateStats), localCandidateStats: appendStatToMap(stats, prevState.localCandidateStats),
})); }));
}, },
remoteCandidateStats: new Map(), remoteCandidateStats: new Map(),
appendRemoteCandidateStats: (stats: RTCIceCandidateStats) => { appendRemoteCandidateStats: stats => {
set(prevState => ({ set(prevState => ({
remoteCandidateStats: appendStatToMap(stats, prevState.remoteCandidateStats), remoteCandidateStats: appendStatToMap(stats, prevState.remoteCandidateStats),
})); }));
}, },
diskDataChannelStats: new Map(), diskDataChannelStats: new Map(),
appendDiskDataChannelStats: (stats: RTCDataChannelStats) => { appendDiskDataChannelStats: stats => {
set(prevState => ({ set(prevState => ({
diskDataChannelStats: appendStatToMap(stats, prevState.diskDataChannelStats), diskDataChannelStats: appendStatToMap(stats, prevState.diskDataChannelStats),
})); }));
@ -250,7 +251,7 @@ export const useRTCStore = create<RTCState>(set => ({
// Add these new properties to the store implementation // Add these new properties to the store implementation
terminalChannel: null, terminalChannel: null,
setTerminalChannel: (channel: RTCDataChannel) => set({ terminalChannel: channel }), setTerminalChannel: channel => set({ terminalChannel: channel }),
})); }));
export interface MouseMove { export interface MouseMove {
@ -270,12 +271,20 @@ export interface MouseState {
export const useMouseStore = create<MouseState>(set => ({ export const useMouseStore = create<MouseState>(set => ({
mouseX: 0, mouseX: 0,
mouseY: 0, mouseY: 0,
setMouseMove: (move?: MouseMove) => set({ mouseMove: move }), setMouseMove: move => set({ mouseMove: move }),
setMousePosition: (x: number, y: number) => set({ mouseX: x, mouseY: y }), setMousePosition: (x, y) => set({ mouseX: x, mouseY: y }),
})); }));
export type HdmiStates = "ready" | "no_signal" | "no_lock" | "out_of_range" | "connecting"; export type HdmiStates =
export type HdmiErrorStates = Extract<VideoState["hdmiState"], "no_signal" | "no_lock" | "out_of_range"> | "ready"
| "no_signal"
| "no_lock"
| "out_of_range"
| "connecting";
export type HdmiErrorStates = Extract<
VideoState["hdmiState"],
"no_signal" | "no_lock" | "out_of_range"
>;
export interface HdmiState { export interface HdmiState {
ready: boolean; ready: boolean;
@ -290,10 +299,7 @@ export interface VideoState {
setClientSize: (width: number, height: number) => void; setClientSize: (width: number, height: number) => void;
setSize: (width: number, height: number) => void; setSize: (width: number, height: number) => void;
hdmiState: HdmiStates; hdmiState: HdmiStates;
setHdmiState: (state: { setHdmiState: (state: { ready: boolean; error?: HdmiErrorStates }) => void;
ready: boolean;
error?: HdmiErrorStates;
}) => void;
} }
export const useVideoStore = create<VideoState>(set => ({ export const useVideoStore = create<VideoState>(set => ({
@ -304,7 +310,8 @@ export const useVideoStore = create<VideoState>(set => ({
clientHeight: 0, clientHeight: 0,
// The video element's client size // The video element's client size
setClientSize: (clientWidth: number, clientHeight: number) => set({ clientWidth, clientHeight }), setClientSize: (clientWidth: number, clientHeight: number) =>
set({ clientWidth, clientHeight }),
// Resolution // Resolution
setSize: (width: number, height: number) => set({ width, height }), setSize: (width: number, height: number) => set({ width, height }),
@ -451,13 +458,15 @@ export interface MountMediaState {
export const useMountMediaStore = create<MountMediaState>(set => ({ export const useMountMediaStore = create<MountMediaState>(set => ({
remoteVirtualMediaState: null, remoteVirtualMediaState: null,
setRemoteVirtualMediaState: (state: MountMediaState["remoteVirtualMediaState"]) => set({ remoteVirtualMediaState: state }), setRemoteVirtualMediaState: (state: MountMediaState["remoteVirtualMediaState"]) =>
set({ remoteVirtualMediaState: state }),
modalView: "mode", modalView: "mode",
setModalView: (view: MountMediaState["modalView"]) => set({ modalView: view }), setModalView: (view: MountMediaState["modalView"]) => set({ modalView: view }),
isMountMediaDialogOpen: false, isMountMediaDialogOpen: false,
setIsMountMediaDialogOpen: (isOpen: MountMediaState["isMountMediaDialogOpen"]) => set({ isMountMediaDialogOpen: isOpen }), setIsMountMediaDialogOpen: (isOpen: MountMediaState["isMountMediaDialogOpen"]) =>
set({ isMountMediaDialogOpen: isOpen }),
uploadedFiles: [], uploadedFiles: [],
addUploadedFile: (file: { name: string; size: string; uploadedAt: string }) => addUploadedFile: (file: { name: string; size: string; uploadedAt: string }) =>
@ -474,7 +483,7 @@ export interface KeyboardLedState {
compose: boolean; compose: boolean;
kana: boolean; kana: boolean;
shift: boolean; // Optional, as not all keyboards have a shift LED shift: boolean; // Optional, as not all keyboards have a shift LED
}; }
export const hidKeyBufferSize = 6; export const hidKeyBufferSize = 6;
export const hidErrorRollOver = 0x01; export const hidErrorRollOver = 0x01;
@ -509,14 +518,23 @@ export interface HidState {
} }
export const useHidStore = create<HidState>(set => ({ export const useHidStore = create<HidState>(set => ({
keyboardLedState: { num_lock: false, caps_lock: false, scroll_lock: false, compose: false, kana: false, shift: false } as KeyboardLedState, keyboardLedState: {
setKeyboardLedState: (ledState: KeyboardLedState): void => set({ keyboardLedState: ledState }), num_lock: false,
caps_lock: false,
scroll_lock: false,
compose: false,
kana: false,
shift: false,
} as KeyboardLedState,
setKeyboardLedState: (ledState: KeyboardLedState): void =>
set({ keyboardLedState: ledState }),
keysDownState: { modifier: 0, keys: [0, 0, 0, 0, 0, 0] } as KeysDownState, keysDownState: { modifier: 0, keys: [0, 0, 0, 0, 0, 0] } as KeysDownState,
setKeysDownState: (state: KeysDownState): void => set({ keysDownState: state }), setKeysDownState: (state: KeysDownState): void => set({ keysDownState: state }),
isVirtualKeyboardEnabled: false, isVirtualKeyboardEnabled: false,
setVirtualKeyboardEnabled: (enabled: boolean): void => set({ isVirtualKeyboardEnabled: enabled }), setVirtualKeyboardEnabled: (enabled: boolean): void =>
set({ isVirtualKeyboardEnabled: enabled }),
isPasteInProgress: false, isPasteInProgress: false,
setPasteModeEnabled: (enabled: boolean): void => set({ isPasteInProgress: enabled }), setPasteModeEnabled: (enabled: boolean): void => set({ isPasteInProgress: enabled }),
@ -568,7 +586,7 @@ export interface OtaState {
systemUpdateProgress: number; systemUpdateProgress: number;
systemUpdatedAt: string | null; systemUpdatedAt: string | null;
}; }
export interface UpdateState { export interface UpdateState {
isUpdatePending: boolean; isUpdatePending: boolean;
@ -580,7 +598,7 @@ export interface UpdateState {
otaState: OtaState; otaState: OtaState;
setOtaState: (state: OtaState) => void; setOtaState: (state: OtaState) => void;
modalView: UpdateModalViews modalView: UpdateModalViews;
setModalView: (view: UpdateModalViews) => void; setModalView: (view: UpdateModalViews) => void;
updateErrorMessage: string | null; updateErrorMessage: string | null;
@ -620,12 +638,11 @@ export const useUpdateStore = create<UpdateState>(set => ({
setModalView: (view: UpdateModalViews) => set({ modalView: view }), setModalView: (view: UpdateModalViews) => set({ modalView: view }),
updateErrorMessage: null, updateErrorMessage: null,
setUpdateErrorMessage: (errorMessage: string) => set({ updateErrorMessage: errorMessage }), setUpdateErrorMessage: (errorMessage: string) =>
set({ updateErrorMessage: errorMessage }),
})); }));
export type UsbConfigModalViews = export type UsbConfigModalViews = "updateUsbConfig" | "updateUsbConfigSuccess";
| "updateUsbConfig"
| "updateUsbConfigSuccess";
export interface UsbConfigModalState { export interface UsbConfigModalState {
modalView: UsbConfigModalViews; modalView: UsbConfigModalViews;
@ -735,6 +752,11 @@ export interface IPv6Address {
flag_tentative?: boolean; flag_tentative?: boolean;
} }
export interface PublicIP {
ip: string;
last_updated: Date;
}
export interface NetworkState { export interface NetworkState {
interface_name?: string; interface_name?: string;
mac_address?: string; mac_address?: string;
@ -978,5 +1000,5 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
} finally { } finally {
set({ loading: false }); set({ loading: false });
} }
} },
})); }));

View File

@ -6,6 +6,7 @@ import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime"; import relativeTime from "dayjs/plugin/relativeTime";
import validator from "validator"; import validator from "validator";
import PublicIPCard from "@components/PublicIPCard";
import { NetworkSettings, NetworkState, useNetworkStateStore, useRTCStore } from "@hooks/stores"; import { NetworkSettings, NetworkState, useNetworkStateStore, useRTCStore } from "@hooks/stores";
import { useJsonRpc } from "@hooks/useJsonRpc"; import { useJsonRpc } from "@hooks/useJsonRpc";
import AutoHeight from "@components/AutoHeight"; import AutoHeight from "@components/AutoHeight";
@ -460,6 +461,8 @@ export default function SettingsNetworkRoute() {
/> />
</SettingsItem> </SettingsItem>
<PublicIPCard />
<div> <div>
<AutoHeight> <AutoHeight>
{formState.isLoading ? ( {formState.isLoading ? (

View File

@ -557,8 +557,9 @@ export default function KvmIdRoute() {
clearCandidatePairStats(); clearCandidatePairStats();
setSidebarView(null); setSidebarView(null);
setPeerConnection(null); setPeerConnection(null);
setRpcDataChannel(null);
}; };
}, [clearCandidatePairStats, clearInboundRtpStats, setPeerConnection, setSidebarView]); }, [clearCandidatePairStats, clearInboundRtpStats, setPeerConnection, setSidebarView, setRpcDataChannel]);
// TURN server usage detection // TURN server usage detection
useEffect(() => { useEffect(() => {

View File

@ -24,17 +24,47 @@ export interface JsonRpcCallResponse<T = unknown> {
let rpcCallCounter = 0; let rpcCallCounter = 0;
// Helper: wait for RTC data channel to be ready // Helper: wait for RTC data channel to be ready
// This waits indefinitely for the channel to be ready, only aborting via the signal
// Throws if the channel instance changed while waiting (stale connection detected)
async function waitForRtcReady(signal: AbortSignal): Promise<RTCDataChannel> { async function waitForRtcReady(signal: AbortSignal): Promise<RTCDataChannel> {
const pollInterval = 100; const pollInterval = 100;
let lastSeenChannel: RTCDataChannel | null = null;
while (!signal.aborted) { while (!signal.aborted) {
const state = useRTCStore.getState(); const state = useRTCStore.getState();
if (state.rpcDataChannel?.readyState === "open") { const currentChannel = state.rpcDataChannel;
return state.rpcDataChannel;
// Channel instance changed (new connection replaced old one)
if (lastSeenChannel && currentChannel && lastSeenChannel !== currentChannel) {
console.debug("[waitForRtcReady] Channel instance changed, aborting wait");
throw new Error("RTC connection changed while waiting for readiness");
} }
// Channel was removed from store (connection closed)
if (lastSeenChannel && !currentChannel) {
console.debug("[waitForRtcReady] Channel was removed from store, aborting wait");
throw new Error("RTC connection was closed while waiting for readiness");
}
// No channel yet, keep waiting
if (!currentChannel) {
await sleep(pollInterval);
continue;
}
// Track this channel instance
lastSeenChannel = currentChannel;
// Channel is ready!
if (currentChannel.readyState === "open") {
return currentChannel;
}
await sleep(pollInterval); await sleep(pollInterval);
} }
// Signal was aborted for some reason
console.debug("[waitForRtcReady] Aborted via signal");
throw new Error("RTC readiness check aborted"); throw new Error("RTC readiness check aborted");
} }
@ -97,25 +127,26 @@ export async function callJsonRpc<T = unknown>(
const timeout = options.attemptTimeoutMs || 5000; const timeout = options.attemptTimeoutMs || 5000;
for (let attempt = 0; attempt < maxAttempts; attempt++) { for (let attempt = 0; attempt < maxAttempts; attempt++) {
const abortController = new AbortController();
const timeoutId = setTimeout(() => abortController.abort(), timeout);
// Exponential backoff for retries that starts at 500ms up to a maximum of 10 seconds // Exponential backoff for retries that starts at 500ms up to a maximum of 10 seconds
const backoffMs = Math.min(500 * Math.pow(2, attempt), 10000); const backoffMs = Math.min(500 * Math.pow(2, attempt), 10000);
let timeoutId: ReturnType<typeof setTimeout> | null = null;
try { try {
// Wait for RTC readiness // Wait for RTC readiness without timeout - this allows time for WebRTC to connect
const rpcDataChannel = await waitForRtcReady(abortController.signal); const readyAbortController = new AbortController();
const rpcDataChannel = await waitForRtcReady(readyAbortController.signal);
// Now apply timeout only to the actual RPC request/response
const rpcAbortController = new AbortController();
timeoutId = setTimeout(() => rpcAbortController.abort(), timeout);
// Send RPC request and wait for response // Send RPC request and wait for response
const response = await sendRpcRequest<T>( const response = await sendRpcRequest<T>(
rpcDataChannel, rpcDataChannel,
options, options,
abortController.signal, rpcAbortController.signal,
); );
clearTimeout(timeoutId);
// Retry on error if attempts remain // Retry on error if attempts remain
if (response.error && attempt < maxAttempts - 1) { if (response.error && attempt < maxAttempts - 1) {
await sleep(backoffMs); await sleep(backoffMs);
@ -124,8 +155,6 @@ export async function callJsonRpc<T = unknown>(
return response; return response;
} catch (error) { } catch (error) {
clearTimeout(timeoutId);
// Retry on timeout/error if attempts remain // Retry on timeout/error if attempts remain
if (attempt < maxAttempts - 1) { if (attempt < maxAttempts - 1) {
await sleep(backoffMs); await sleep(backoffMs);
@ -135,6 +164,10 @@ export async function callJsonRpc<T = unknown>(
throw error instanceof Error throw error instanceof Error
? error ? error
: new Error(`JSON-RPC call failed after ${timeout}ms`); : new Error(`JSON-RPC call failed after ${timeout}ms`);
} finally {
if (timeoutId !== null) {
clearTimeout(timeoutId);
}
} }
} }