Skip to content

Commit ef7f40a

Browse files
committed
feat(cli): add aws check to ping p2p diagnostics
1 parent df1bee7 commit ef7f40a

File tree

5 files changed

+237
-2
lines changed

5 files changed

+237
-2
lines changed

cli/cliui/agent.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,8 @@ type ConnDiags struct {
357357
LocalNetInfo *tailcfg.NetInfo
358358
LocalInterfaces *healthsdk.InterfacesReport
359359
AgentNetcheck *healthsdk.AgentNetcheckReport
360+
ClientIPIsAWS bool
361+
AgentIPIsAWS bool
360362
// TODO: More diagnostics
361363
}
362364

@@ -375,7 +377,6 @@ func ConnDiagnostics(w io.Writer, d ConnDiags) {
375377

376378
if d.PingP2P {
377379
_, _ = fmt.Fprint(w, "✔ You are connected directly (p2p)\n")
378-
return
379380
}
380381
_, _ = fmt.Fprint(w, "❗ You are connected via a DERP relay, not directly (p2p)\n")
381382

@@ -400,4 +401,12 @@ func ConnDiagnostics(w io.Writer, d ConnDiags) {
400401
if d.AgentNetcheck != nil && d.AgentNetcheck.NetInfo != nil && d.AgentNetcheck.NetInfo.MappingVariesByDestIP.EqualBool(true) {
401402
_, _ = fmt.Fprint(w, "❗ Agent is potentially behind a hard NAT, as multiple endpoints were retrieved from different STUN servers\n")
402403
}
404+
405+
if d.ClientIPIsAWS {
406+
_, _ = fmt.Fprint(w, "❗ Client IP address is within an AWS range, and is therefore behind a hard NAT\n")
407+
}
408+
409+
if d.AgentIPIsAWS {
410+
_, _ = fmt.Fprint(w, "❗ Agent IP address is within an AWS range, and is therefore behind a hard NAT\n")
411+
}
403412
}

cli/cliui/agent_test.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -782,6 +782,28 @@ func TestConnDiagnostics(t *testing.T) {
782782
`✔ You are connected directly (p2p)`,
783783
},
784784
},
785+
{
786+
name: "ClientAWSIP",
787+
diags: cliui.ConnDiags{
788+
ClientIPIsAWS: true,
789+
AgentIPIsAWS: false,
790+
},
791+
want: []string{
792+
`❗ You are connected via a DERP relay, not directly (p2p)`,
793+
`❗ Client IP address is within an AWS range, and is therefore behind a hard NAT`,
794+
},
795+
},
796+
{
797+
name: "AgentAWSIP",
798+
diags: cliui.ConnDiags{
799+
ClientIPIsAWS: false,
800+
AgentIPIsAWS: true,
801+
},
802+
want: []string{
803+
`❗ You are connected via a DERP relay, not directly (p2p)`,
804+
`❗ Agent IP address is within an AWS range, and is therefore behind a hard NAT`,
805+
},
806+
},
785807
}
786808
for _, tc := range testCases {
787809
tc := tc

cli/cliutil/awscheck.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package cliutil
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"io"
7+
"net/http"
8+
"net/netip"
9+
"time"
10+
11+
"golang.org/x/xerrors"
12+
)
13+
14+
const awsIPRangesURL = "https://ip-ranges.amazonaws.com/ip-ranges.json"
15+
16+
type AWSIPv4Prefix struct {
17+
Prefix string `json:"ip_prefix"`
18+
Region string `json:"region"`
19+
Service string `json:"service"`
20+
NetworkBorderGroup string `json:"network_border_group"`
21+
}
22+
23+
type AWSIPv6Prefix struct {
24+
Prefix string `json:"ipv6_prefix"`
25+
Region string `json:"region"`
26+
Service string `json:"service"`
27+
}
28+
29+
type AWSIPRanges struct {
30+
SyncToken string `json:"syncToken"`
31+
CreateDate string `json:"createDate"`
32+
IPV4Prefixes []AWSIPv4Prefix `json:"prefixes"`
33+
IPV6Prefixes []AWSIPv6Prefix `json:"ipv6_prefixes"`
34+
}
35+
36+
func NewAWSIPRanges(ctx context.Context) (*AWSIPRanges, error) {
37+
client := &http.Client{}
38+
reqCtx, reqCancel := context.WithTimeout(ctx, 5*time.Second)
39+
defer reqCancel()
40+
req, _ := http.NewRequestWithContext(reqCtx, http.MethodGet, awsIPRangesURL, nil)
41+
resp, err := client.Do(req)
42+
if err != nil {
43+
return nil, err
44+
}
45+
defer resp.Body.Close()
46+
47+
if resp.StatusCode != http.StatusOK {
48+
b, _ := io.ReadAll(resp.Body)
49+
return nil, xerrors.Errorf("unexpected status code %d: %s", resp.StatusCode, b)
50+
}
51+
52+
var out AWSIPRanges
53+
err = json.NewDecoder(resp.Body).Decode(&out)
54+
if err != nil {
55+
return nil, xerrors.Errorf("json decode: %w", err)
56+
}
57+
return &out, nil
58+
}
59+
60+
// CheckIP checks if the given IP address is an AWS IP.
61+
func (r *AWSIPRanges) CheckIP(ip netip.Addr) (bool, error) {
62+
if ip.IsLoopback() || ip.IsLinkLocalMulticast() || ip.IsLinkLocalUnicast() || ip.IsPrivate() {
63+
return false, nil
64+
}
65+
66+
if ip.Is4() {
67+
for _, p := range r.IPV4Prefixes {
68+
prefix, err := netip.ParsePrefix(p.Prefix)
69+
if err != nil {
70+
return false, xerrors.Errorf("parse ip prefix: %w", err)
71+
}
72+
if prefix.Contains(ip) {
73+
return true, nil
74+
}
75+
}
76+
} else {
77+
for _, p := range r.IPV6Prefixes {
78+
prefix, err := netip.ParsePrefix(p.Prefix)
79+
if err != nil {
80+
return false, xerrors.Errorf("parse ip prefix: %w", err)
81+
}
82+
if prefix.Contains(ip) {
83+
return true, nil
84+
}
85+
}
86+
}
87+
return false, nil
88+
}

cli/cliutil/awscheck_test.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package cliutil_test
2+
3+
import (
4+
"net/netip"
5+
"testing"
6+
7+
"github.com/stretchr/testify/require"
8+
9+
"github.com/coder/coder/v2/cli/cliutil"
10+
"github.com/coder/coder/v2/testutil"
11+
)
12+
13+
func TestIPV4Check(t *testing.T) {
14+
t.Parallel()
15+
ctx := testutil.Context(t, testutil.WaitShort)
16+
ranges, err := cliutil.NewAWSIPRanges(ctx)
17+
require.NoError(t, err)
18+
19+
t.Run("Private/IPV4", func(t *testing.T) {
20+
t.Parallel()
21+
ip, err := netip.ParseAddr("192.168.0.1")
22+
require.NoError(t, err)
23+
isAws, err := ranges.CheckIP(ip)
24+
require.NoError(t, err)
25+
require.False(t, isAws)
26+
})
27+
28+
t.Run("AWS/IPV4", func(t *testing.T) {
29+
t.Parallel()
30+
ip, err := netip.ParseAddr("3.25.61.113")
31+
require.NoError(t, err)
32+
isAws, err := ranges.CheckIP(ip)
33+
require.NoError(t, err)
34+
require.True(t, isAws)
35+
})
36+
37+
t.Run("NonAWS/IPV4", func(t *testing.T) {
38+
t.Parallel()
39+
ip, err := netip.ParseAddr("159.196.123.40")
40+
require.NoError(t, err)
41+
isAws, err := ranges.CheckIP(ip)
42+
require.NoError(t, err)
43+
require.False(t, isAws)
44+
})
45+
46+
t.Run("Private/IPV6", func(t *testing.T) {
47+
t.Parallel()
48+
ip, err := netip.ParseAddr("::1")
49+
require.NoError(t, err)
50+
isAws, err := ranges.CheckIP(ip)
51+
require.NoError(t, err)
52+
require.False(t, isAws)
53+
})
54+
55+
t.Run("AWS/IPV6", func(t *testing.T) {
56+
t.Parallel()
57+
ip, err := netip.ParseAddr("2600:9000:5206:0001:0000:0000:0000:0001")
58+
require.NoError(t, err)
59+
isAws, err := ranges.CheckIP(ip)
60+
require.NoError(t, err)
61+
require.True(t, isAws)
62+
})
63+
64+
t.Run("NonAWS/IPV6", func(t *testing.T) {
65+
t.Parallel()
66+
ip, err := netip.ParseAddr("2403:5807:885f:0:a544:49d4:58f8:aedf")
67+
require.NoError(t, err)
68+
isAws, err := ranges.CheckIP(ip)
69+
require.NoError(t, err)
70+
require.False(t, isAws)
71+
})
72+
}

cli/ping.go

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,19 @@ import (
55
"errors"
66
"fmt"
77
"net/http"
8+
"net/netip"
89
"time"
910

1011
"golang.org/x/xerrors"
12+
"tailscale.com/tailcfg"
1113

1214
"cdr.dev/slog"
1315
"cdr.dev/slog/sloggers/sloghuman"
1416

1517
"github.com/coder/pretty"
1618

1719
"github.com/coder/coder/v2/cli/cliui"
20+
"github.com/coder/coder/v2/cli/cliutil"
1821
"github.com/coder/coder/v2/codersdk"
1922
"github.com/coder/coder/v2/codersdk/healthsdk"
2023
"github.com/coder/coder/v2/codersdk/workspacesdk"
@@ -150,11 +153,24 @@ func (r *RootCmd) ping() *serpent.Command {
150153
diags := conn.GetPeerDiagnostics()
151154
cliui.PeerDiagnostics(inv.Stdout, diags)
152155

156+
ni := conn.GetNetInfo()
153157
connDiags := cliui.ConnDiags{
154158
PingP2P: didP2p,
155159
DisableDirect: r.disableDirect,
156-
LocalNetInfo: conn.GetNetInfo(),
160+
LocalNetInfo: ni,
157161
}
162+
163+
awsRanges, err := cliutil.NewAWSIPRanges(ctx)
164+
if err != nil {
165+
_, _ = fmt.Fprintf(inv.Stdout, "Failed to retrieve AWS IP ranges: %v\n", err)
166+
}
167+
168+
clientIPIsAWS, err := isAWSIP(awsRanges, ni)
169+
if err != nil {
170+
_, _ = fmt.Fprintf(inv.Stdout, "Failed to determine if client IP is AWS: %v\n", err)
171+
}
172+
connDiags.ClientIPIsAWS = clientIPIsAWS
173+
158174
connInfo, err := client.AgentConnectionInfoGeneric(ctx)
159175
if err == nil {
160176
connDiags.ConnInfo = &connInfo
@@ -167,9 +183,15 @@ func (r *RootCmd) ping() *serpent.Command {
167183
} else {
168184
_, _ = fmt.Fprintf(inv.Stdout, "Failed to retrieve local interfaces report: %v\n", err)
169185
}
186+
170187
agentNetcheck, err := conn.Netcheck(ctx)
171188
if err == nil {
172189
connDiags.AgentNetcheck = &agentNetcheck
190+
agentIPIsAws, err := isAWSIP(awsRanges, agentNetcheck.NetInfo)
191+
if err != nil {
192+
_, _ = fmt.Fprintf(inv.Stdout, "Failed to determine if agent IP is AWS: %v\n", err)
193+
}
194+
connDiags.AgentIPIsAWS = agentIPIsAws
173195
} else {
174196
var sdkErr *codersdk.Error
175197
if errors.As(err, &sdkErr) && sdkErr.StatusCode() == http.StatusNotFound {
@@ -178,6 +200,7 @@ func (r *RootCmd) ping() *serpent.Command {
178200
_, _ = fmt.Fprintf(inv.Stdout, "Failed to retrieve connection report from agent: %v\n", err)
179201
}
180202
}
203+
181204
cliui.ConnDiagnostics(inv.Stdout, connDiags)
182205
return nil
183206
},
@@ -207,3 +230,24 @@ func (r *RootCmd) ping() *serpent.Command {
207230
}
208231
return cmd
209232
}
233+
234+
func isAWSIP(awsRanges *cliutil.AWSIPRanges, ni *tailcfg.NetInfo) (bool, error) {
235+
var strIP string
236+
if ni.GlobalV4 != "" {
237+
strIP = ni.GlobalV4
238+
} else if ni.GlobalV6 != "" {
239+
strIP = ni.GlobalV6
240+
} else {
241+
return false, xerrors.Errorf("no public IP address found")
242+
}
243+
244+
ip, err := netip.ParseAddr(strIP)
245+
if err != nil {
246+
return false, err
247+
}
248+
isAWS, err := awsRanges.CheckIP(ip)
249+
if err != nil {
250+
return false, err
251+
}
252+
return isAWS, nil
253+
}

0 commit comments

Comments
 (0)