From 37c9f8689acf4a2a327b7e9d7c6dd078073c08d2 Mon Sep 17 00:00:00 2001 From: Siyuan Date: Tue, 4 Nov 2025 12:52:58 +0000 Subject: [PATCH] feat: add public IP card to settings network page --- .vscode/settings.json | 3 +- jsonrpc.go | 6 + main.go | 1 + network.go | 70 ++++++ pkg/myip/check.go | 142 ++++++++++++ pkg/myip/ip.go | 209 ++++++++++++++++++ ui/localization/messages/en.json | 5 +- ui/src/components/PublicIPCard.tsx | 86 +++++++ ui/src/components/sidebar/connectionStats.tsx | 36 +-- ui/src/hooks/stores.ts | 5 + .../routes/devices.$id.settings.network.tsx | 41 ++-- 11 files changed, 565 insertions(+), 39 deletions(-) create mode 100644 pkg/myip/check.go create mode 100644 pkg/myip/ip.go create mode 100644 ui/src/components/PublicIPCard.tsx diff --git a/.vscode/settings.json b/.vscode/settings.json index ba3550bf..41aeee58 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -10,5 +10,6 @@ ] }, "git.ignoreLimitWarning": true, - "cmake.sourceDirectory": "/workspaces/kvm-static-ip/internal/native/cgo" + "cmake.sourceDirectory": "/workspaces/kvm-static-ip/internal/native/cgo", + "cmake.ignoreCMakeListsMissing": true } \ No newline at end of file diff --git a/jsonrpc.go b/jsonrpc.go index 5ed90a7a..46d03864 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -932,6 +932,10 @@ func rpcSetCloudUrl(apiUrl string, appUrl string) error { disconnectCloud(fmt.Errorf("cloud url changed from %s to %s", currentCloudURL, apiUrl)) } + if publicIPState != nil { + publicIPState.SetCloudflareEndpoint(apiUrl) + } + if err := SaveConfig(); err != nil { return fmt.Errorf("failed to save config: %w", err) } @@ -1248,4 +1252,6 @@ var rpcHandlers = map[string]RPCHandler{ "setKeyboardMacros": {Func: setKeyboardMacros, Params: []string{"params"}}, "getLocalLoopbackOnly": {Func: rpcGetLocalLoopbackOnly}, "setLocalLoopbackOnly": {Func: rpcSetLocalLoopbackOnly, Params: []string{"enabled"}}, + "getPublicIPAddresses": {Func: rpcGetPublicIPAddresses, Params: []string{"refresh"}}, + "checkPublicIPAddresses": {Func: rpcCheckPublicIPAddresses}, } diff --git a/main.go b/main.go index bcc2d73d..66545bf6 100644 --- a/main.go +++ b/main.go @@ -126,6 +126,7 @@ func Main() { // As websocket client already checks if the cloud token is set, we can start it here. go RunWebsocketClient() + initPublicIPState() initSerialPort() sigs := make(chan os.Signal, 1) diff --git a/network.go b/network.go index 846f41f1..42fc6d87 100644 --- a/network.go +++ b/network.go @@ -3,12 +3,17 @@ package kvm import ( "context" "fmt" + "net" + "net/http" "reflect" + "time" "github.com/jetkvm/kvm/internal/confparser" "github.com/jetkvm/kvm/internal/mdns" "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/link" ) const ( @@ -17,6 +22,7 @@ const ( var ( networkManager *nmlite.NetworkManager + publicIPState *myip.PublicIPState ) type RpcNetworkSettings struct { @@ -115,6 +121,14 @@ func networkStateChanged(_ string, state types.InterfaceState) { if state.Online { networkLogger.Info().Msg("network state changed to online, triggering time sync") 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 @@ -164,6 +178,40 @@ func initNetwork() error { 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 { if nm == nil { return nil @@ -312,3 +360,25 @@ func rpcToggleDHCPClient() error { 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() +} diff --git a/pkg/myip/check.go b/pkg/myip/check.go new file mode 100644 index 00000000..9879bee7 --- /dev/null +++ b/pkg/myip/check.go @@ -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) +} diff --git a/pkg/myip/ip.go b/pkg/myip/ip.go new file mode 100644 index 00000000..ef66188e --- /dev/null +++ b/pkg/myip/ip.go @@ -0,0 +1,209 @@ +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() + } + 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 + } + } +} diff --git a/ui/localization/messages/en.json b/ui/localization/messages/en.json index 2642f795..c8a5c0b7 100644 --- a/ui/localization/messages/en.json +++ b/ui/localization/messages/en.json @@ -914,5 +914,8 @@ "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" + "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}" } diff --git a/ui/src/components/PublicIPCard.tsx b/ui/src/components/PublicIPCard.tsx new file mode 100644 index 00000000..a2671753 --- /dev/null +++ b/ui/src/components/PublicIPCard.tsx @@ -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([]); + 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 ( + +
+
+
+

+ {m.public_ip_card_header()} +

+ +
+
+
+ {publicIPs.length === 0 ? ( +
+
+
+
+
+
+
+
+
+ ) : ( +
+
+ {publicIPs?.map(ip => ( +
+ + {ip.ip} + +
+ ))} +
+
+ )} +
+
+ + ); +} diff --git a/ui/src/components/sidebar/connectionStats.tsx b/ui/src/components/sidebar/connectionStats.tsx index c66206f2..637f1597 100644 --- a/ui/src/components/sidebar/connectionStats.tsx +++ b/ui/src/components/sidebar/connectionStats.tsx @@ -119,25 +119,25 @@ export default function ConnectionStatsSidebar() { /> {remoteIPAddress && (
-
- {m.connection_stats_remote_ip_address()} -
-
- -
- {remoteIPAddress} {" "} +
+ {m.connection_stats_remote_ip_address()}
- -
-
+
+ +
+ {remoteIPAddress} +
+
+
+
)} + +
{formState.isLoading ? ( @@ -540,25 +543,25 @@ export default function SettingsNetworkRoute() {
- { isLLDPAvailable && - ( -
- - - -
- ) + {isLLDPAvailable && + ( +
+ + + +
+ ) }