Skip to content

Commit 15438e6

Browse files
committed
feat(cli): add aws check to ping p2p diagnostics
1 parent 8c15192 commit 15438e6

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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy