Skip to content

Commit 44ca668

Browse files
committed
feat: add public IP card to settings network page
1 parent 8a54d4c commit 44ca668

File tree

11 files changed

+565
-39
lines changed

11 files changed

+565
-39
lines changed

.vscode/settings.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,6 @@
1010
]
1111
},
1212
"git.ignoreLimitWarning": true,
13-
"cmake.sourceDirectory": "/workspaces/kvm-static-ip/internal/native/cgo"
13+
"cmake.sourceDirectory": "/workspaces/kvm-static-ip/internal/native/cgo",
14+
"cmake.ignoreCMakeListsMissing": true
1415
}

jsonrpc.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -932,6 +932,10 @@ func rpcSetCloudUrl(apiUrl string, appUrl string) error {
932932
disconnectCloud(fmt.Errorf("cloud url changed from %s to %s", currentCloudURL, apiUrl))
933933
}
934934

935+
if publicIPState != nil {
936+
publicIPState.SetCloudflareEndpoint(apiUrl)
937+
}
938+
935939
if err := SaveConfig(); err != nil {
936940
return fmt.Errorf("failed to save config: %w", err)
937941
}
@@ -1248,4 +1252,6 @@ var rpcHandlers = map[string]RPCHandler{
12481252
"setKeyboardMacros": {Func: setKeyboardMacros, Params: []string{"params"}},
12491253
"getLocalLoopbackOnly": {Func: rpcGetLocalLoopbackOnly},
12501254
"setLocalLoopbackOnly": {Func: rpcSetLocalLoopbackOnly, Params: []string{"enabled"}},
1255+
"getPublicIPAddresses": {Func: rpcGetPublicIPAddresses, Params: []string{"refresh"}},
1256+
"checkPublicIPAddresses": {Func: rpcCheckPublicIPAddresses},
12511257
}

main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ func Main() {
126126

127127
// As websocket client already checks if the cloud token is set, we can start it here.
128128
go RunWebsocketClient()
129+
initPublicIPState()
129130

130131
initSerialPort()
131132
sigs := make(chan os.Signal, 1)

network.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,17 @@ package kvm
33
import (
44
"context"
55
"fmt"
6+
"net"
7+
"net/http"
68
"reflect"
9+
"time"
710

811
"github.com/jetkvm/kvm/internal/confparser"
912
"github.com/jetkvm/kvm/internal/mdns"
1013
"github.com/jetkvm/kvm/internal/network/types"
14+
"github.com/jetkvm/kvm/pkg/myip"
1115
"github.com/jetkvm/kvm/pkg/nmlite"
16+
"github.com/jetkvm/kvm/pkg/nmlite/link"
1217
)
1318

1419
const (
@@ -17,6 +22,7 @@ const (
1722

1823
var (
1924
networkManager *nmlite.NetworkManager
25+
publicIPState *myip.PublicIPState
2026
)
2127

2228
type RpcNetworkSettings struct {
@@ -115,6 +121,14 @@ func networkStateChanged(_ string, state types.InterfaceState) {
115121
if state.Online {
116122
networkLogger.Info().Msg("network state changed to online, triggering time sync")
117123
triggerTimeSyncOnNetworkStateChange()
124+
125+
if publicIPState != nil {
126+
publicIPState.SetIPv4AndIPv6(state.IPv4Ready, state.IPv6Ready)
127+
}
128+
} else {
129+
if publicIPState != nil {
130+
publicIPState.SetIPv4AndIPv6(false, false)
131+
}
118132
}
119133

120134
// always restart mDNS when the network state changes
@@ -164,6 +178,40 @@ func initNetwork() error {
164178
return nil
165179
}
166180

181+
func initPublicIPState() {
182+
// the feature will be only enabled if the cloud has been adopted
183+
// due to privacy reasons
184+
185+
// but it will be initialized anyway to avoid nil pointer dereferences
186+
ps := myip.NewPublicIPState(&myip.PublicIPStateConfig{
187+
Logger: networkLogger,
188+
CloudflareEndpoint: config.CloudURL,
189+
APIEndpoint: "",
190+
IPv4: false,
191+
IPv6: false,
192+
HttpClientGetter: func(family int) *http.Client {
193+
transport := http.DefaultTransport.(*http.Transport).Clone()
194+
transport.Proxy = config.NetworkConfig.GetTransportProxyFunc()
195+
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
196+
netType := network
197+
switch family {
198+
case link.AfInet:
199+
netType = "tcp4"
200+
case link.AfInet6:
201+
netType = "tcp6"
202+
}
203+
return (&net.Dialer{}).DialContext(ctx, netType, addr)
204+
}
205+
206+
return &http.Client{
207+
Transport: transport,
208+
Timeout: 30 * time.Second,
209+
}
210+
},
211+
})
212+
publicIPState = ps
213+
}
214+
167215
func setHostname(nm *nmlite.NetworkManager, hostname, domain string) error {
168216
if nm == nil {
169217
return nil
@@ -312,3 +360,25 @@ func rpcToggleDHCPClient() error {
312360

313361
return rpcReboot(true)
314362
}
363+
364+
func rpcGetPublicIPAddresses(refresh bool) ([]myip.PublicIP, error) {
365+
if publicIPState == nil {
366+
return nil, fmt.Errorf("public IP state not initialized")
367+
}
368+
369+
if refresh {
370+
if err := publicIPState.ForceUpdate(); err != nil {
371+
return nil, err
372+
}
373+
}
374+
375+
return publicIPState.GetAddresses(), nil
376+
}
377+
378+
func rpcCheckPublicIPAddresses() error {
379+
if publicIPState == nil {
380+
return fmt.Errorf("public IP state not initialized")
381+
}
382+
383+
return publicIPState.ForceUpdate()
384+
}

pkg/myip/check.go

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
package myip
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"io"
7+
"net"
8+
"net/http"
9+
"net/url"
10+
"strings"
11+
"sync"
12+
"time"
13+
14+
"github.com/jetkvm/kvm/pkg/nmlite/link"
15+
)
16+
17+
func (ps *PublicIPState) request(ctx context.Context, url string, family int) ([]byte, error) {
18+
client := ps.httpClient(family)
19+
20+
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
21+
if err != nil {
22+
return nil, fmt.Errorf("error creating request: %w", err)
23+
}
24+
25+
resp, err := client.Do(req)
26+
if err != nil {
27+
return nil, fmt.Errorf("error sending request: %w", err)
28+
}
29+
defer resp.Body.Close()
30+
31+
if resp.StatusCode != http.StatusOK {
32+
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
33+
}
34+
35+
body, err := io.ReadAll(resp.Body)
36+
if err != nil {
37+
return nil, fmt.Errorf("error reading response body: %w", err)
38+
}
39+
40+
return body, err
41+
}
42+
43+
// checkCloudflare uses cdn-cgi/trace to get the public IP address
44+
func (ps *PublicIPState) checkCloudflare(ctx context.Context, family int) (*PublicIP, error) {
45+
u, err := url.JoinPath(ps.cloudflareEndpoint, "/cdn-cgi/trace")
46+
if err != nil {
47+
return nil, fmt.Errorf("error joining path: %w", err)
48+
}
49+
50+
body, err := ps.request(ctx, u, family)
51+
if err != nil {
52+
return nil, err
53+
}
54+
55+
for line := range strings.SplitSeq(string(body), "\n") {
56+
key, value, ok := strings.Cut(line, "=")
57+
if !ok || key != "ip" {
58+
continue
59+
}
60+
61+
return &PublicIP{
62+
IPAddress: net.ParseIP(value),
63+
LastUpdated: time.Now(),
64+
}, nil
65+
}
66+
67+
return nil, fmt.Errorf("no IP address found")
68+
}
69+
70+
// checkAPI uses the API endpoint to get the public IP address
71+
func (ps *PublicIPState) checkAPI(_ context.Context, _ int) (*PublicIP, error) {
72+
return nil, fmt.Errorf("not implemented")
73+
}
74+
75+
// checkIPs checks both IPv4 and IPv6 public IP addresses in parallel
76+
// and updates the IPAddresses slice with the results
77+
func (ps *PublicIPState) checkIPs(ctx context.Context, checkIPv4, checkIPv6 bool) error {
78+
var wg sync.WaitGroup
79+
var mu sync.Mutex
80+
var ips []PublicIP
81+
var errors []error
82+
83+
checkFamily := func(family int, familyName string) {
84+
wg.Add(1)
85+
go func(f int, name string) {
86+
defer wg.Done()
87+
88+
ip, err := ps.checkIPForFamily(ctx, f)
89+
mu.Lock()
90+
defer mu.Unlock()
91+
if err != nil {
92+
errors = append(errors, fmt.Errorf("%s check failed: %w", name, err))
93+
return
94+
}
95+
if ip != nil {
96+
ips = append(ips, *ip)
97+
}
98+
}(family, familyName)
99+
}
100+
101+
if checkIPv4 {
102+
checkFamily(link.AfInet, "IPv4")
103+
}
104+
105+
if checkIPv6 {
106+
checkFamily(link.AfInet6, "IPv6")
107+
}
108+
109+
wg.Wait()
110+
111+
if len(ips) > 0 {
112+
ps.mu.Lock()
113+
defer ps.mu.Unlock()
114+
115+
ps.addresses = ips
116+
ps.lastUpdated = time.Now()
117+
}
118+
119+
if len(errors) > 0 && len(ips) == 0 {
120+
return errors[0]
121+
}
122+
123+
return nil
124+
}
125+
126+
func (ps *PublicIPState) checkIPForFamily(ctx context.Context, family int) (*PublicIP, error) {
127+
if ps.apiEndpoint != "" {
128+
ip, err := ps.checkAPI(ctx, family)
129+
if err == nil && ip != nil {
130+
return ip, nil
131+
}
132+
}
133+
134+
if ps.cloudflareEndpoint != "" {
135+
ip, err := ps.checkCloudflare(ctx, family)
136+
if err == nil && ip != nil {
137+
return ip, nil
138+
}
139+
}
140+
141+
return nil, fmt.Errorf("all IP check methods failed for family %d", family)
142+
}

0 commit comments

Comments
 (0)