Skip to content

Commit a92e814

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

File tree

5 files changed

+275
-3
lines changed

5 files changed

+275
-3
lines changed

cli/cliui/agent.go

Lines changed: 12 additions & 2 deletions
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,9 +377,9 @@ func ConnDiagnostics(w io.Writer, d ConnDiags) {
375377

376378
if d.PingP2P {
377379
_, _ = fmt.Fprint(w, "✔ You are connected directly (p2p)\n")
378-
return
380+
} else {
381+
_, _ = fmt.Fprint(w, "❗ You are connected via a DERP relay, not directly (p2p)\n")
379382
}
380-
_, _ = fmt.Fprint(w, "❗ You are connected via a DERP relay, not directly (p2p)\n")
381383

382384
if d.DisableDirect {
383385
_, _ = fmt.Fprint(w, "❗ Direct connections are disabled locally, by `--disable-direct` or `CODER_DISABLE_DIRECT`\n")
@@ -400,4 +402,12 @@ func ConnDiagnostics(w io.Writer, d ConnDiags) {
400402
if d.AgentNetcheck != nil && d.AgentNetcheck.NetInfo != nil && d.AgentNetcheck.NetInfo.MappingVariesByDestIP.EqualBool(true) {
401403
_, _ = fmt.Fprint(w, "❗ Agent is potentially behind a hard NAT, as multiple endpoints were retrieved from different STUN servers\n")
402404
}
405+
406+
if d.ClientIPIsAWS {
407+
_, _ = fmt.Fprint(w, "❗ Client IP address is within an AWS range, which is known to cause problems with forming direct connections (AWS uses hard NAT)\n")
408+
}
409+
410+
if d.AgentIPIsAWS {
411+
_, _ = fmt.Fprint(w, "❗ Agent IP address is within an AWS range, which is known to cause problems with forming direct connections (AWS uses hard NAT)\n")
412+
}
403413
}

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, which is known to cause problems with forming direct connections (AWS uses 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, which is known to cause problems with forming direct connections (AWS uses hard NAT)`,
805+
},
806+
},
785807
}
786808
for _, tc := range testCases {
787809
tc := tc

cli/cliutil/awscheck.go

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
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+
NetworkBorderGroup string `json:"network_border_group"`
28+
}
29+
30+
type AWSIPRanges struct {
31+
V4 []netip.Prefix
32+
V6 []netip.Prefix
33+
}
34+
35+
type awsIPRangesResponse struct {
36+
SyncToken string `json:"syncToken"`
37+
CreateDate string `json:"createDate"`
38+
IPV4Prefixes []awsIPv4Prefix `json:"prefixes"`
39+
IPV6Prefixes []awsIPv6Prefix `json:"ipv6_prefixes"`
40+
}
41+
42+
func FetchAWSIPRanges(ctx context.Context, url string) (*AWSIPRanges, error) {
43+
client := &http.Client{}
44+
reqCtx, reqCancel := context.WithTimeout(ctx, 5*time.Second)
45+
defer reqCancel()
46+
req, _ := http.NewRequestWithContext(reqCtx, http.MethodGet, url, nil)
47+
resp, err := client.Do(req)
48+
if err != nil {
49+
return nil, err
50+
}
51+
defer resp.Body.Close()
52+
53+
if resp.StatusCode != http.StatusOK {
54+
b, _ := io.ReadAll(resp.Body)
55+
return nil, xerrors.Errorf("unexpected status code %d: %s", resp.StatusCode, b)
56+
}
57+
58+
var body awsIPRangesResponse
59+
err = json.NewDecoder(resp.Body).Decode(&body)
60+
if err != nil {
61+
return nil, xerrors.Errorf("json decode: %w", err)
62+
}
63+
64+
out := &AWSIPRanges{
65+
V4: make([]netip.Prefix, 0, len(body.IPV4Prefixes)),
66+
V6: make([]netip.Prefix, 0, len(body.IPV6Prefixes)),
67+
}
68+
69+
for _, p := range body.IPV4Prefixes {
70+
prefix, err := netip.ParsePrefix(p.Prefix)
71+
if err != nil {
72+
return nil, xerrors.Errorf("parse ip prefix: %w", err)
73+
}
74+
if prefix.Addr().Is6() {
75+
return nil, xerrors.Errorf("ipv4 prefix contains ipv6 address: %s", p.Prefix)
76+
}
77+
out.V4 = append(out.V4, prefix)
78+
}
79+
80+
for _, p := range body.IPV6Prefixes {
81+
prefix, err := netip.ParsePrefix(p.Prefix)
82+
if err != nil {
83+
return nil, xerrors.Errorf("parse ip prefix: %w", err)
84+
}
85+
if prefix.Addr().Is4() {
86+
return nil, xerrors.Errorf("ipv6 prefix contains ipv4 address: %s", p.Prefix)
87+
}
88+
out.V6 = append(out.V6, prefix)
89+
}
90+
91+
return out, nil
92+
}
93+
94+
// CheckIP checks if the given IP address is an AWS IP.
95+
func (r *AWSIPRanges) CheckIP(ip netip.Addr) bool {
96+
if ip.IsLoopback() || ip.IsLinkLocalMulticast() || ip.IsLinkLocalUnicast() || ip.IsPrivate() {
97+
return false
98+
}
99+
100+
if ip.Is4() {
101+
for _, p := range r.V4 {
102+
if p.Contains(ip) {
103+
return true
104+
}
105+
}
106+
} else {
107+
for _, p := range r.V6 {
108+
if p.Contains(ip) {
109+
return true
110+
}
111+
}
112+
}
113+
return false
114+
}

cli/cliutil/awscheck_internal_test.go

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package cliutil
2+
3+
import (
4+
"context"
5+
"net/http"
6+
"net/http/httptest"
7+
"net/netip"
8+
"testing"
9+
10+
"github.com/stretchr/testify/require"
11+
12+
"github.com/coder/coder/v2/coderd/httpapi"
13+
"github.com/coder/coder/v2/testutil"
14+
)
15+
16+
func TestIPV4Check(t *testing.T) {
17+
t.Parallel()
18+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
19+
httpapi.Write(context.Background(), w, http.StatusOK, awsIPRangesResponse{
20+
IPV4Prefixes: []awsIPv4Prefix{
21+
{
22+
Prefix: "3.24.0.0/14",
23+
},
24+
{
25+
Prefix: "15.230.15.29/32",
26+
},
27+
{
28+
Prefix: "47.128.82.100/31",
29+
},
30+
},
31+
IPV6Prefixes: []awsIPv6Prefix{
32+
{
33+
Prefix: "2600:9000:5206::/48",
34+
},
35+
{
36+
Prefix: "2406:da70:8800::/40",
37+
},
38+
{
39+
Prefix: "2600:1f68:5000::/40",
40+
},
41+
},
42+
})
43+
}))
44+
ctx := testutil.Context(t, testutil.WaitShort)
45+
ranges, err := FetchAWSIPRanges(ctx, srv.URL)
46+
require.NoError(t, err)
47+
48+
t.Run("Private/IPV4", func(t *testing.T) {
49+
t.Parallel()
50+
ip, err := netip.ParseAddr("192.168.0.1")
51+
require.NoError(t, err)
52+
isAws := ranges.CheckIP(ip)
53+
require.False(t, isAws)
54+
})
55+
56+
t.Run("AWS/IPV4", func(t *testing.T) {
57+
t.Parallel()
58+
ip, err := netip.ParseAddr("3.25.61.113")
59+
require.NoError(t, err)
60+
isAws := ranges.CheckIP(ip)
61+
require.True(t, isAws)
62+
})
63+
64+
t.Run("NonAWS/IPV4", func(t *testing.T) {
65+
t.Parallel()
66+
ip, err := netip.ParseAddr("159.196.123.40")
67+
require.NoError(t, err)
68+
isAws := ranges.CheckIP(ip)
69+
require.False(t, isAws)
70+
})
71+
72+
t.Run("Private/IPV6", func(t *testing.T) {
73+
t.Parallel()
74+
ip, err := netip.ParseAddr("::1")
75+
require.NoError(t, err)
76+
isAws := ranges.CheckIP(ip)
77+
require.False(t, isAws)
78+
})
79+
80+
t.Run("AWS/IPV6", func(t *testing.T) {
81+
t.Parallel()
82+
ip, err := netip.ParseAddr("2600:9000:5206:0001:0000:0000:0000:0001")
83+
require.NoError(t, err)
84+
isAws := ranges.CheckIP(ip)
85+
require.True(t, isAws)
86+
})
87+
88+
t.Run("NonAWS/IPV6", func(t *testing.T) {
89+
t.Parallel()
90+
ip, err := netip.ParseAddr("2403:5807:885f:0:a544:49d4:58f8:aedf")
91+
require.NoError(t, err)
92+
isAws := ranges.CheckIP(ip)
93+
require.False(t, isAws)
94+
})
95+
}

cli/ping.go

Lines changed: 32 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,20 @@ 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.FetchAWSIPRanges(ctx, cliutil.AWSIPRangesURL)
164+
if err != nil {
165+
_, _ = fmt.Fprintf(inv.Stdout, "Failed to retrieve AWS IP ranges: %v\n", err)
166+
}
167+
168+
connDiags.ClientIPIsAWS = isAWSIP(awsRanges, ni)
169+
158170
connInfo, err := client.AgentConnectionInfoGeneric(ctx)
159171
if err == nil {
160172
connDiags.ConnInfo = &connInfo
@@ -167,9 +179,11 @@ func (r *RootCmd) ping() *serpent.Command {
167179
} else {
168180
_, _ = fmt.Fprintf(inv.Stdout, "Failed to retrieve local interfaces report: %v\n", err)
169181
}
182+
170183
agentNetcheck, err := conn.Netcheck(ctx)
171184
if err == nil {
172185
connDiags.AgentNetcheck = &agentNetcheck
186+
connDiags.AgentIPIsAWS = isAWSIP(awsRanges, agentNetcheck.NetInfo)
173187
} else {
174188
var sdkErr *codersdk.Error
175189
if errors.As(err, &sdkErr) && sdkErr.StatusCode() == http.StatusNotFound {
@@ -178,6 +192,7 @@ func (r *RootCmd) ping() *serpent.Command {
178192
_, _ = fmt.Fprintf(inv.Stdout, "Failed to retrieve connection report from agent: %v\n", err)
179193
}
180194
}
195+
181196
cliui.ConnDiagnostics(inv.Stdout, connDiags)
182197
return nil
183198
},
@@ -207,3 +222,19 @@ func (r *RootCmd) ping() *serpent.Command {
207222
}
208223
return cmd
209224
}
225+
226+
func isAWSIP(awsRanges *cliutil.AWSIPRanges, ni *tailcfg.NetInfo) bool {
227+
if ni.GlobalV4 != "" {
228+
ip, err := netip.ParseAddr(ni.GlobalV4)
229+
if err == nil && awsRanges.CheckIP(ip) {
230+
return true
231+
}
232+
}
233+
if ni.GlobalV6 != "" {
234+
ip, err := netip.ParseAddr(ni.GlobalV6)
235+
if err == nil && awsRanges.CheckIP(ip) {
236+
return true
237+
}
238+
}
239+
return false
240+
}

0 commit comments

Comments
 (0)