Skip to content

chore(vpn): send ping results over tunnel #18200

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Jun 6, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 8 additions & 37 deletions cli/ssh.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import (
"path/filepath"
"regexp"
"slices"
"strconv"
"strings"
"sync"
"time"
Expand All @@ -31,7 +30,6 @@ import (
"golang.org/x/term"
"golang.org/x/xerrors"
"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
"tailscale.com/tailcfg"
"tailscale.com/types/netlogtype"

"cdr.dev/slog"
Expand All @@ -40,11 +38,13 @@ import (
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/cli/cliutil"
"github.com/coder/coder/v2/coderd/autobuild/notify"
"github.com/coder/coder/v2/coderd/util/maps"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/workspacesdk"
"github.com/coder/coder/v2/cryptorand"
"github.com/coder/coder/v2/pty"
"github.com/coder/coder/v2/tailnet"
"github.com/coder/quartz"
"github.com/coder/retry"
"github.com/coder/serpent"
Expand Down Expand Up @@ -1456,28 +1456,6 @@ func collectNetworkStats(ctx context.Context, agentConn *workspacesdk.AgentConn,
}
node := agentConn.Node()
derpMap := agentConn.DERPMap()
derpLatency := map[string]float64{}

// Convert DERP region IDs to friendly names for display in the UI.
for rawRegion, latency := range node.DERPLatency {
regionParts := strings.SplitN(rawRegion, "-", 2)
regionID, err := strconv.Atoi(regionParts[0])
if err != nil {
continue
}
region, found := derpMap.Regions[regionID]
if !found {
// It's possible that a workspace agent is using an old DERPMap
// and reports regions that do not exist. If that's the case,
// report the region as unknown!
region = &tailcfg.DERPRegion{
RegionID: regionID,
RegionName: fmt.Sprintf("Unnamed %d", regionID),
}
}
// Convert the microseconds to milliseconds.
derpLatency[region.RegionName] = latency * 1000
}

totalRx := uint64(0)
totalTx := uint64(0)
Expand All @@ -1491,27 +1469,20 @@ func collectNetworkStats(ctx context.Context, agentConn *workspacesdk.AgentConn,
uploadSecs := float64(totalTx) / dur.Seconds()
downloadSecs := float64(totalRx) / dur.Seconds()

// Sometimes the preferred DERP doesn't match the one we're actually
// connected with. Perhaps because the agent prefers a different DERP and
// we're using that server instead.
preferredDerpID := node.PreferredDERP
if pingResult.DERPRegionID != 0 {
preferredDerpID = pingResult.DERPRegionID
}
preferredDerp, ok := derpMap.Regions[preferredDerpID]
preferredDerpName := fmt.Sprintf("Unnamed %d", preferredDerpID)
if ok {
preferredDerpName = preferredDerp.RegionName
}
preferredDerpName := tailnet.ExtractPreferredDERPName(pingResult, node, derpMap)
derpLatency := tailnet.ExtractDERPLatency(node, derpMap)
if _, ok := derpLatency[preferredDerpName]; !ok {
derpLatency[preferredDerpName] = 0
}
derpLatencyMs := maps.Map(derpLatency, func(dur time.Duration) float64 {
return float64(dur) / float64(time.Millisecond)
})

return &sshNetworkStats{
P2P: p2p,
Latency: float64(latency.Microseconds()) / 1000,
PreferredDERP: preferredDerpName,
DERPLatency: derpLatency,
DERPLatency: derpLatencyMs,
UploadBytesSec: int64(uploadSecs),
DownloadBytesSec: int64(downloadSecs),
}, nil
Expand Down
8 changes: 0 additions & 8 deletions coderd/database/db2sdk/db2sdk.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,6 @@ func ListLazy[F any, T any](convert func(F) T) func(list []F) []T {
}
}

func Map[K comparable, F any, T any](params map[K]F, convert func(F) T) map[K]T {
into := make(map[K]T)
for k, item := range params {
into[k] = convert(item)
}
return into
}

type ExternalAuthMeta struct {
Authenticated bool
ValidateError string
Expand Down
8 changes: 8 additions & 0 deletions coderd/util/maps/maps.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ import (
"golang.org/x/exp/constraints"
)

func Map[K comparable, F any, T any](params map[K]F, convert func(F) T) map[K]T {
into := make(map[K]T)
for k, item := range params {
into[k] = convert(item)
}
return into
}

// Subset returns true if all the keys of a are present
// in b and have the same values.
// If the corresponding value of a[k] is the zero value in
Expand Down
46 changes: 46 additions & 0 deletions tailnet/derpmap.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@ import (
"net/http"
"os"
"strconv"
"strings"
"time"

"golang.org/x/xerrors"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
)

Expand Down Expand Up @@ -152,6 +155,49 @@ regionLoop:
return derpMap, nil
}

func ExtractPreferredDERPName(pingResult *ipnstate.PingResult, node *Node, derpMap *tailcfg.DERPMap) string {
// Sometimes the preferred DERP doesn't match the one we're actually
// connected with. Perhaps because the agent prefers a different DERP and
// we're using that server instead.
preferredDerpID := node.PreferredDERP
if pingResult.DERPRegionID != 0 {
preferredDerpID = pingResult.DERPRegionID
}
preferredDerp, ok := derpMap.Regions[preferredDerpID]
preferredDerpName := fmt.Sprintf("Unnamed %d", preferredDerpID)
if ok {
preferredDerpName = preferredDerp.RegionName
}

return preferredDerpName
}

// ExtractDERPLatency extracts a map of derp region names to their latencies
func ExtractDERPLatency(node *Node, derpMap *tailcfg.DERPMap) map[string]time.Duration {
latencyMs := make(map[string]time.Duration)

// Convert DERP region IDs to friendly names for display in the UI.
for rawRegion, latency := range node.DERPLatency {
regionParts := strings.SplitN(rawRegion, "-", 2)
regionID, err := strconv.Atoi(regionParts[0])
if err != nil {
continue
}
region, found := derpMap.Regions[regionID]
if !found {
// It's possible that a workspace agent is using an old DERPMap
// and reports regions that do not exist. If that's the case,
// report the region as unknown!
region = &tailcfg.DERPRegion{
RegionID: regionID,
RegionName: fmt.Sprintf("Unnamed %d", regionID),
}
}
latencyMs[region.RegionName] = time.Duration(latency * float64(time.Second))
}
return latencyMs
}

// CompareDERPMaps returns true if the given DERPMaps are equivalent. Ordering
// of slices is ignored.
//
Expand Down
109 changes: 109 additions & 0 deletions tailnet/derpmap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"testing"

"github.com/stretchr/testify/require"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"

"github.com/coder/coder/v2/tailnet"
Expand Down Expand Up @@ -162,3 +163,111 @@ func TestNewDERPMap(t *testing.T) {
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)
})
}
10 changes: 10 additions & 0 deletions vpn/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,14 @@ import (
"net/http"
"net/netip"
"net/url"
"time"

"golang.org/x/xerrors"

"tailscale.com/ipn/ipnstate"
"tailscale.com/net/dns"
"tailscale.com/net/netmon"
"tailscale.com/tailcfg"
"tailscale.com/wgengine/router"

"github.com/google/uuid"
Expand All @@ -27,6 +30,9 @@ import (
type Conn interface {
CurrentWorkspaceState() (tailnet.WorkspaceUpdate, error)
GetPeerDiagnostics(peerID uuid.UUID) tailnet.PeerDiagnostics
Ping(ctx context.Context, agentID uuid.UUID) (time.Duration, bool, *ipnstate.PingResult, error)
Node() *tailnet.Node
DERPMap() *tailcfg.DERPMap
Close() error
}

Expand All @@ -38,6 +44,10 @@ type vpnConn struct {
updatesCtrl *tailnet.TunnelAllWorkspaceUpdatesController
}

func (c *vpnConn) Ping(ctx context.Context, agentID uuid.UUID) (time.Duration, bool, *ipnstate.PingResult, error) {
return c.Conn.Ping(ctx, tailnet.TailscaleServicePrefix.AddrFromUUID(agentID))
}

func (c *vpnConn) CurrentWorkspaceState() (tailnet.WorkspaceUpdate, error) {
return c.updatesCtrl.CurrentState()
}
Expand Down
9 changes: 2 additions & 7 deletions vpn/speaker_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ func TestMain(m *testing.M) {
goleak.VerifyTestMain(m, testutil.GoleakOptions...)
}

const expectedHandshake = "codervpn tunnel 1.2\n"

// TestSpeaker_RawPeer tests the speaker with a peer that we simulate by directly making reads and
// writes to the other end of the pipe. There should be at least one test that does this, rather
// than use 2 speakers so that we don't have a bug where we don't adhere to the stated protocol, but
Expand All @@ -48,8 +50,6 @@ func TestSpeaker_RawPeer(t *testing.T) {
errCh <- err
}()

expectedHandshake := "codervpn tunnel 1.1\n"

b := make([]byte, 256)
n, err := mp.Read(b)
require.NoError(t, err)
Expand Down Expand Up @@ -157,8 +157,6 @@ func TestSpeaker_OversizeHandshake(t *testing.T) {
errCh <- err
}()

expectedHandshake := "codervpn tunnel 1.1\n"

b := make([]byte, 256)
n, err := mp.Read(b)
require.NoError(t, err)
Expand Down Expand Up @@ -210,7 +208,6 @@ func TestSpeaker_HandshakeInvalid(t *testing.T) {
_, err = mp.Write([]byte(tc.handshake))
require.NoError(t, err)

expectedHandshake := "codervpn tunnel 1.1\n"
b := make([]byte, 256)
n, err := mp.Read(b)
require.NoError(t, err)
Expand Down Expand Up @@ -248,8 +245,6 @@ func TestSpeaker_CorruptMessage(t *testing.T) {
errCh <- err
}()

expectedHandshake := "codervpn tunnel 1.1\n"

b := make([]byte, 256)
n, err := mp.Read(b)
require.NoError(t, err)
Expand Down
Loading
Loading