mirror of
https://github.com/coder/coder.git
synced 2025-07-12 00:14:10 +00:00
Closes #17982. The purpose of this PR is to expose network latency via the API used by Coder Desktop. This PR has the tunnel ping all known agents every 5 seconds, in order to produce an instance of: ```proto message LastPing { // latency is the RTT of the ping to the agent. google.protobuf.Duration latency = 1; // did_p2p indicates whether the ping was sent P2P, or over DERP. bool did_p2p = 2; // preferred_derp is the human readable name of the preferred DERP region, // or the region used for the last ping, if it was sent over DERP. string preferred_derp = 3; // preferred_derp_latency is the last known latency to the preferred DERP // region. Unset if the region does not appear in the DERP map. optional google.protobuf.Duration preferred_derp_latency = 4; } ``` The contents of this message are stored and included on all subsequent upsertions of the agent. Note that we upsert existing agents every 5 seconds to update the `last_handshake` value. On the desktop apps, this message will be used to produce a tooltip similar to that of the VS Code extension: <img width="495" alt="image" src="https://github.com/user-attachments/assets/d8b65f3d-f536-4c64-9af9-35c1a42c92d2" /> (wording not final) Unlike the VS Code extension, we omit: - The Latency of *all* available DERP regions. It seems not ideal to send a copy of this entire map for every online agent, and it certainly doesn't make sense for it to be on the `Agent` or `LastPing` message. If we do want to expose this info on Coder Desktop, we should consider how best to do so; maybe we want to include it on a more generic `Netcheck` message. - The current throughput (Bytes up/down). This is out of scope of the linked issue, and is non-trivial to implement. I'm also not sure of the value given the frequency we're doing these status updates (every 5 seconds). If we want to expose it, it'll be in a separate PR. <img width="343" alt="image" src="https://github.com/user-attachments/assets/8447d03b-9721-4111-8ac1-332d70a1e8f1" />
274 lines
7.4 KiB
Go
274 lines
7.4 KiB
Go
package tailnet_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
"tailscale.com/ipn/ipnstate"
|
|
"tailscale.com/tailcfg"
|
|
|
|
"github.com/coder/coder/v2/tailnet"
|
|
)
|
|
|
|
func TestNewDERPMap(t *testing.T) {
|
|
t.Parallel()
|
|
t.Run("WithoutRemoteURL", func(t *testing.T) {
|
|
t.Parallel()
|
|
derpMap, err := tailnet.NewDERPMap(context.Background(), &tailcfg.DERPRegion{
|
|
RegionID: 1,
|
|
Nodes: []*tailcfg.DERPNode{{}},
|
|
}, []string{"stun.google.com:2345"}, "", "", false)
|
|
require.NoError(t, err)
|
|
require.Len(t, derpMap.Regions, 2)
|
|
require.Len(t, derpMap.Regions[1].Nodes, 1)
|
|
require.Len(t, derpMap.Regions[2].Nodes, 1)
|
|
})
|
|
t.Run("RemoteURL", func(t *testing.T) {
|
|
t.Parallel()
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
data, _ := json.Marshal(&tailcfg.DERPMap{
|
|
Regions: map[int]*tailcfg.DERPRegion{
|
|
1: {
|
|
RegionID: 1,
|
|
Nodes: []*tailcfg.DERPNode{{}},
|
|
},
|
|
},
|
|
})
|
|
_, _ = w.Write(data)
|
|
}))
|
|
t.Cleanup(server.Close)
|
|
derpMap, err := tailnet.NewDERPMap(context.Background(), &tailcfg.DERPRegion{
|
|
RegionID: 2,
|
|
}, []string{}, server.URL, "", false)
|
|
require.NoError(t, err)
|
|
require.Len(t, derpMap.Regions, 2)
|
|
})
|
|
t.Run("RemoteConflicts", func(t *testing.T) {
|
|
t.Parallel()
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
data, _ := json.Marshal(&tailcfg.DERPMap{
|
|
Regions: map[int]*tailcfg.DERPRegion{
|
|
1: {},
|
|
},
|
|
})
|
|
_, _ = w.Write(data)
|
|
}))
|
|
t.Cleanup(server.Close)
|
|
_, err := tailnet.NewDERPMap(context.Background(), &tailcfg.DERPRegion{
|
|
RegionID: 1,
|
|
}, []string{}, server.URL, "", false)
|
|
require.Error(t, err)
|
|
})
|
|
t.Run("LocalPath", func(t *testing.T) {
|
|
t.Parallel()
|
|
localPath := filepath.Join(t.TempDir(), "derp.json")
|
|
content, err := json.Marshal(&tailcfg.DERPMap{
|
|
Regions: map[int]*tailcfg.DERPRegion{
|
|
1: {
|
|
Nodes: []*tailcfg.DERPNode{{}},
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
err = os.WriteFile(localPath, content, 0o600)
|
|
require.NoError(t, err)
|
|
derpMap, err := tailnet.NewDERPMap(context.Background(), &tailcfg.DERPRegion{
|
|
RegionID: 2,
|
|
}, []string{}, "", localPath, false)
|
|
require.NoError(t, err)
|
|
require.Len(t, derpMap.Regions, 2)
|
|
})
|
|
t.Run("DisableSTUN", func(t *testing.T) {
|
|
t.Parallel()
|
|
localPath := filepath.Join(t.TempDir(), "derp.json")
|
|
content, err := json.Marshal(&tailcfg.DERPMap{
|
|
Regions: map[int]*tailcfg.DERPRegion{
|
|
1: {
|
|
Nodes: []*tailcfg.DERPNode{{
|
|
STUNPort: 1234,
|
|
}},
|
|
},
|
|
2: {
|
|
Nodes: []*tailcfg.DERPNode{
|
|
{
|
|
STUNPort: 1234,
|
|
},
|
|
{
|
|
STUNPort: 12345,
|
|
},
|
|
{
|
|
STUNOnly: true,
|
|
STUNPort: 54321,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
err = os.WriteFile(localPath, content, 0o600)
|
|
require.NoError(t, err)
|
|
region := &tailcfg.DERPRegion{
|
|
RegionID: 3,
|
|
Nodes: []*tailcfg.DERPNode{{
|
|
STUNPort: 1234,
|
|
}},
|
|
}
|
|
derpMap, err := tailnet.NewDERPMap(context.Background(), region, []string{"127.0.0.1:54321"}, "", localPath, true)
|
|
require.NoError(t, err)
|
|
require.Len(t, derpMap.Regions, 3)
|
|
|
|
require.Len(t, derpMap.Regions[1].Nodes, 1)
|
|
require.EqualValues(t, -1, derpMap.Regions[1].Nodes[0].STUNPort)
|
|
// The STUNOnly node should get removed.
|
|
require.Len(t, derpMap.Regions[2].Nodes, 2)
|
|
require.EqualValues(t, -1, derpMap.Regions[2].Nodes[0].STUNPort)
|
|
require.False(t, derpMap.Regions[2].Nodes[0].STUNOnly)
|
|
require.EqualValues(t, -1, derpMap.Regions[2].Nodes[1].STUNPort)
|
|
require.False(t, derpMap.Regions[2].Nodes[1].STUNOnly)
|
|
// We don't add any nodes ourselves if STUN is disabled.
|
|
require.Len(t, derpMap.Regions[3].Nodes, 1)
|
|
// ... but we still remove the STUN port from existing nodes in the
|
|
// region.
|
|
require.EqualValues(t, -1, derpMap.Regions[3].Nodes[0].STUNPort)
|
|
})
|
|
t.Run("RequireRegions", func(t *testing.T) {
|
|
t.Parallel()
|
|
_, err := tailnet.NewDERPMap(context.Background(), nil, nil, "", "", false)
|
|
require.Error(t, err)
|
|
require.ErrorContains(t, err, "DERP map has no regions")
|
|
})
|
|
t.Run("RequireDERPNodes", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// No nodes.
|
|
_, err := tailnet.NewDERPMap(context.Background(), &tailcfg.DERPRegion{}, nil, "", "", false)
|
|
require.Error(t, err)
|
|
require.ErrorContains(t, err, "DERP map has no DERP nodes")
|
|
|
|
// No DERP nodes.
|
|
_, err = tailnet.NewDERPMap(context.Background(), &tailcfg.DERPRegion{
|
|
Nodes: []*tailcfg.DERPNode{
|
|
{
|
|
STUNOnly: true,
|
|
},
|
|
},
|
|
}, nil, "", "", false)
|
|
require.Error(t, err)
|
|
require.ErrorContains(t, err, "DERP map has no DERP nodes")
|
|
})
|
|
}
|
|
|
|
func TestExtractDERPLatency(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
derpMap := &tailcfg.DERPMap{
|
|
Regions: map[int]*tailcfg.DERPRegion{
|
|
1: {
|
|
RegionID: 1,
|
|
RegionName: "Region One",
|
|
Nodes: []*tailcfg.DERPNode{
|
|
{Name: "node1", RegionID: 1},
|
|
},
|
|
},
|
|
2: {
|
|
RegionID: 2,
|
|
RegionName: "Region Two",
|
|
Nodes: []*tailcfg.DERPNode{
|
|
{Name: "node2", RegionID: 2},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
t.Run("Basic", func(t *testing.T) {
|
|
t.Parallel()
|
|
node := &tailnet.Node{
|
|
DERPLatency: map[string]float64{
|
|
"1-node1": 0.05,
|
|
"2-node2": 0.1,
|
|
},
|
|
}
|
|
latencyMs := tailnet.ExtractDERPLatency(node, derpMap)
|
|
require.EqualValues(t, 50, latencyMs["Region One"].Milliseconds())
|
|
require.EqualValues(t, 100, latencyMs["Region Two"].Milliseconds())
|
|
require.Len(t, latencyMs, 2)
|
|
})
|
|
|
|
t.Run("UnknownRegion", func(t *testing.T) {
|
|
t.Parallel()
|
|
node := &tailnet.Node{
|
|
DERPLatency: map[string]float64{
|
|
"999-node999": 0.2,
|
|
},
|
|
}
|
|
latencyMs := tailnet.ExtractDERPLatency(node, derpMap)
|
|
require.EqualValues(t, 200, latencyMs["Unnamed 999"].Milliseconds())
|
|
require.Len(t, latencyMs, 1)
|
|
})
|
|
|
|
t.Run("InvalidRegionFormat", func(t *testing.T) {
|
|
t.Parallel()
|
|
node := &tailnet.Node{
|
|
DERPLatency: map[string]float64{
|
|
"invalid": 0.3,
|
|
"1-node1": 0.05,
|
|
"abc-node": 0.15,
|
|
},
|
|
}
|
|
latencyMs := tailnet.ExtractDERPLatency(node, derpMap)
|
|
require.EqualValues(t, 50, latencyMs["Region One"].Milliseconds())
|
|
require.Len(t, latencyMs, 1)
|
|
require.NotContains(t, latencyMs, "invalid")
|
|
require.NotContains(t, latencyMs, "abc-node")
|
|
})
|
|
|
|
t.Run("EmptyInput", func(t *testing.T) {
|
|
t.Parallel()
|
|
node := &tailnet.Node{
|
|
DERPLatency: map[string]float64{},
|
|
}
|
|
latencyMs := tailnet.ExtractDERPLatency(node, derpMap)
|
|
require.Empty(t, latencyMs)
|
|
})
|
|
}
|
|
|
|
func TestExtractPreferredDERPName(t *testing.T) {
|
|
t.Parallel()
|
|
derpMap := &tailcfg.DERPMap{
|
|
Regions: map[int]*tailcfg.DERPRegion{
|
|
1: {RegionName: "New York"},
|
|
2: {RegionName: "London"},
|
|
},
|
|
}
|
|
|
|
t.Run("UsesPingRegion", func(t *testing.T) {
|
|
t.Parallel()
|
|
pingResult := &ipnstate.PingResult{DERPRegionID: 2}
|
|
node := &tailnet.Node{PreferredDERP: 1}
|
|
result := tailnet.ExtractPreferredDERPName(pingResult, node, derpMap)
|
|
require.Equal(t, "London", result)
|
|
})
|
|
|
|
t.Run("UsesNodePreferred", func(t *testing.T) {
|
|
t.Parallel()
|
|
pingResult := &ipnstate.PingResult{DERPRegionID: 0}
|
|
node := &tailnet.Node{PreferredDERP: 1}
|
|
result := tailnet.ExtractPreferredDERPName(pingResult, node, derpMap)
|
|
require.Equal(t, "New York", result)
|
|
})
|
|
|
|
t.Run("UnknownRegion", func(t *testing.T) {
|
|
t.Parallel()
|
|
pingResult := &ipnstate.PingResult{DERPRegionID: 99}
|
|
node := &tailnet.Node{PreferredDERP: 1}
|
|
result := tailnet.ExtractPreferredDERPName(pingResult, node, derpMap)
|
|
require.Equal(t, "Unnamed 99", result)
|
|
})
|
|
}
|