fix: use client preferred URL for the default DERP (#18911)

The agentsdk currently does a remap of the DERP map to change the
EmbeddedRelay node's URL to match the agent's access URL.

This PR makes changes to the `workspacesdk` (used by clients like the
CLI) and `vpn` (used by Coder Desktop) to match this behavior.

This enables us the ability to try Coder clients in dogfood over a VPN
without changing the global access URL.
This commit is contained in:
Dean Sheather
2025-07-17 20:17:44 +10:00
committed by GitHub
parent fb00cd2c1a
commit a1b87a67c6
11 changed files with 248 additions and 56 deletions
+1 -1
View File
@@ -98,7 +98,7 @@ type Client interface {
ConnectRPC26(ctx context.Context) ( ConnectRPC26(ctx context.Context) (
proto.DRPCAgentClient26, tailnetproto.DRPCTailnetClient26, error, proto.DRPCAgentClient26, tailnetproto.DRPCTailnetClient26, error,
) )
RewriteDERPMap(derpMap *tailcfg.DERPMap) tailnet.DERPMapRewriter
} }
type Agent interface { type Agent interface {
+1 -1
View File
@@ -98,7 +98,7 @@ func NewServerTailnet(
controller := tailnet.NewController(logger, dialer) controller := tailnet.NewController(logger, dialer)
// it's important to set the DERPRegionDialer above _before_ we set the DERP map so that if // it's important to set the DERPRegionDialer above _before_ we set the DERP map so that if
// there is an embedded relay, we use the local in-memory dialer. // there is an embedded relay, we use the local in-memory dialer.
controller.DERPCtrl = tailnet.NewBasicDERPController(logger, conn) controller.DERPCtrl = tailnet.NewBasicDERPController(logger, nil, conn)
coordCtrl := NewMultiAgentController(serverCtx, logger, tracer, conn) coordCtrl := NewMultiAgentController(serverCtx, logger, tracer, conn)
controller.CoordCtrl = coordCtrl controller.CoordCtrl = coordCtrl
// TODO: support controller.TelemetryCtrl // TODO: support controller.TelemetryCtrl
+6 -33
View File
@@ -8,7 +8,6 @@ import (
"net/http" "net/http"
"net/http/cookiejar" "net/http/cookiejar"
"net/url" "net/url"
"strconv"
"time" "time"
"cloud.google.com/go/compute/metadata" "cloud.google.com/go/compute/metadata"
@@ -27,6 +26,7 @@ import (
"github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/drpcsdk" "github.com/coder/coder/v2/codersdk/drpcsdk"
"github.com/coder/coder/v2/tailnet"
tailnetproto "github.com/coder/coder/v2/tailnet/proto" tailnetproto "github.com/coder/coder/v2/tailnet/proto"
) )
@@ -126,40 +126,13 @@ type Script struct {
Script string `json:"script"` Script string `json:"script"`
} }
// RewriteDERPMap rewrites the DERP map to use the access URL of the SDK as the // RewriteDERPMap rewrites the DERP map to use the configured access URL of the
// "embedded relay" access URL. The passed derp map is modified in place. // agent as the "embedded relay" access URL.
// //
// Agents can provide an arbitrary access URL that may be different that the // See tailnet.RewriteDERPMapDefaultRelay for more details on why this is
// globally configured one. This breaks the built-in DERP, which would continue // necessary.
// to reference the global access URL.
func (c *Client) RewriteDERPMap(derpMap *tailcfg.DERPMap) { func (c *Client) RewriteDERPMap(derpMap *tailcfg.DERPMap) {
accessingPort := c.SDK.URL.Port() tailnet.RewriteDERPMapDefaultRelay(context.Background(), c.SDK.Logger(), derpMap, c.SDK.URL)
if accessingPort == "" {
accessingPort = "80"
if c.SDK.URL.Scheme == "https" {
accessingPort = "443"
}
}
accessPort, err := strconv.Atoi(accessingPort)
if err != nil {
// this should never happen because URL.Port() returns the empty string if the port is not
// valid.
c.SDK.Logger().Critical(context.Background(), "failed to parse URL port", slog.F("port", accessingPort))
}
for _, region := range derpMap.Regions {
if !region.EmbeddedRelay {
continue
}
for _, node := range region.Nodes {
if node.STUNOnly {
continue
}
node.HostName = c.SDK.URL.Hostname()
node.DERPPort = accessPort
node.ForceHTTP = c.SDK.URL.Scheme == "http"
}
}
} }
// ConnectRPC20 returns a dRPC client to the Agent API v2.0. Notably, it is missing // ConnectRPC20 returns a dRPC client to the Agent API v2.0. Notably, it is missing
+30
View File
@@ -5,10 +5,12 @@ import (
"io" "io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url"
"testing" "testing"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"tailscale.com/tailcfg"
"cdr.dev/slog/sloggers/slogtest" "cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/v2/codersdk/agentsdk" "github.com/coder/coder/v2/codersdk/agentsdk"
@@ -120,3 +122,31 @@ func TestStreamAgentReinitEvents(t *testing.T) {
require.ErrorIs(t, receiveErr, context.Canceled) require.ErrorIs(t, receiveErr, context.Canceled)
}) })
} }
func TestRewriteDERPMap(t *testing.T) {
t.Parallel()
// This test ensures that RewriteDERPMap mutates built-in DERPs with the
// client access URL.
dm := &tailcfg.DERPMap{
Regions: map[int]*tailcfg.DERPRegion{
1: {
EmbeddedRelay: true,
RegionID: 1,
Nodes: []*tailcfg.DERPNode{{
HostName: "bananas.org",
DERPPort: 1,
}},
},
},
}
parsed, err := url.Parse("https://coconuts.org:44558")
require.NoError(t, err)
client := agentsdk.New(parsed)
client.RewriteDERPMap(dm)
region := dm.Regions[1]
require.True(t, region.EmbeddedRelay)
require.Len(t, region.Nodes, 1)
node := region.Nodes[0]
require.Equal(t, "coconuts.org", node.HostName)
require.Equal(t, 44558, node.DERPPort)
}
+12 -1
View File
@@ -193,6 +193,15 @@ type DialAgentOptions struct {
EnableTelemetry bool EnableTelemetry bool
} }
// RewriteDERPMap rewrites the DERP map to use the configured access URL of the
// client as the "embedded relay" access URL.
//
// See tailnet.RewriteDERPMapDefaultRelay for more details on why this is
// necessary.
func (c *Client) RewriteDERPMap(derpMap *tailcfg.DERPMap) {
tailnet.RewriteDERPMapDefaultRelay(context.Background(), c.client.Logger(), derpMap, c.client.URL)
}
func (c *Client) DialAgent(dialCtx context.Context, agentID uuid.UUID, options *DialAgentOptions) (agentConn *AgentConn, err error) { func (c *Client) DialAgent(dialCtx context.Context, agentID uuid.UUID, options *DialAgentOptions) (agentConn *AgentConn, err error) {
if options == nil { if options == nil {
options = &DialAgentOptions{} options = &DialAgentOptions{}
@@ -248,6 +257,8 @@ func (c *Client) DialAgent(dialCtx context.Context, agentID uuid.UUID, options *
telemetrySink = basicTel telemetrySink = basicTel
controller.TelemetryCtrl = basicTel controller.TelemetryCtrl = basicTel
} }
c.RewriteDERPMap(connInfo.DERPMap)
conn, err := tailnet.NewConn(&tailnet.Options{ conn, err := tailnet.NewConn(&tailnet.Options{
Addresses: []netip.Prefix{netip.PrefixFrom(ip, 128)}, Addresses: []netip.Prefix{netip.PrefixFrom(ip, 128)},
DERPMap: connInfo.DERPMap, DERPMap: connInfo.DERPMap,
@@ -270,7 +281,7 @@ func (c *Client) DialAgent(dialCtx context.Context, agentID uuid.UUID, options *
coordCtrl := tailnet.NewTunnelSrcCoordController(options.Logger, conn) coordCtrl := tailnet.NewTunnelSrcCoordController(options.Logger, conn)
coordCtrl.AddDestination(agentID) coordCtrl.AddDestination(agentID)
controller.CoordCtrl = coordCtrl controller.CoordCtrl = coordCtrl
controller.DERPCtrl = tailnet.NewBasicDERPController(options.Logger, conn) controller.DERPCtrl = tailnet.NewBasicDERPController(options.Logger, c, conn)
controller.Run(ctx) controller.Run(ctx)
options.Logger.Debug(ctx, "running tailnet API v2+ connector") options.Logger.Debug(ctx, "running tailnet API v2+ connector")
+1 -2
View File
@@ -19,7 +19,6 @@ import (
"github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/agentsdk"
"github.com/coder/coder/v2/codersdk/workspacesdk" "github.com/coder/coder/v2/codersdk/workspacesdk"
"github.com/coder/coder/v2/tailnet" "github.com/coder/coder/v2/tailnet"
"github.com/coder/coder/v2/testutil" "github.com/coder/coder/v2/testutil"
@@ -43,7 +42,7 @@ func TestWorkspaceRewriteDERPMap(t *testing.T) {
} }
parsed, err := url.Parse("https://coconuts.org:44558") parsed, err := url.Parse("https://coconuts.org:44558")
require.NoError(t, err) require.NoError(t, err)
client := agentsdk.New(parsed) client := workspacesdk.New(codersdk.New(parsed))
client.RewriteDERPMap(dm) client.RewriteDERPMap(dm)
region := dm.Regions[1] region := dm.Regions[1]
require.True(t, region.EmbeddedRelay) require.True(t, region.EmbeddedRelay)
+19 -8
View File
@@ -568,13 +568,15 @@ type DERPMapSetter interface {
} }
type basicDERPController struct { type basicDERPController struct {
logger slog.Logger logger slog.Logger
setter DERPMapSetter rewriter DERPMapRewriter // optional
setter DERPMapSetter
} }
func (b *basicDERPController) New(client DERPClient) CloserWaiter { func (b *basicDERPController) New(client DERPClient) CloserWaiter {
l := &derpSetLoop{ l := &derpSetLoop{
logger: b.logger, logger: b.logger,
rewriter: b.rewriter,
setter: b.setter, setter: b.setter,
client: client, client: client,
errChan: make(chan error, 1), errChan: make(chan error, 1),
@@ -584,17 +586,23 @@ func (b *basicDERPController) New(client DERPClient) CloserWaiter {
return l return l
} }
func NewBasicDERPController(logger slog.Logger, setter DERPMapSetter) DERPController { // NewBasicDERPController creates a DERP controller that rewrites the DERP map
// with the provided rewriter before setting it on the provided setter.
//
// The rewriter is optional and can be nil.
func NewBasicDERPController(logger slog.Logger, rewriter DERPMapRewriter, setter DERPMapSetter) DERPController {
return &basicDERPController{ return &basicDERPController{
logger: logger, logger: logger,
setter: setter, rewriter: rewriter,
setter: setter,
} }
} }
type derpSetLoop struct { type derpSetLoop struct {
logger slog.Logger logger slog.Logger
setter DERPMapSetter rewriter DERPMapRewriter // optional
client DERPClient setter DERPMapSetter
client DERPClient
sync.Mutex sync.Mutex
closed bool closed bool
@@ -640,6 +648,9 @@ func (l *derpSetLoop) recvLoop() {
return return
} }
l.logger.Debug(context.Background(), "got new DERP Map", slog.F("derp_map", dm)) l.logger.Debug(context.Background(), "got new DERP Map", slog.F("derp_map", dm))
if l.rewriter != nil {
l.rewriter.RewriteDERPMap(dm)
}
l.setter.SetDERPMap(dm) l.setter.SetDERPMap(dm)
} }
} }
+91 -3
View File
@@ -586,7 +586,7 @@ func TestNewBasicDERPController_Mainline(t *testing.T) {
t.Parallel() t.Parallel()
fs := make(chan *tailcfg.DERPMap) fs := make(chan *tailcfg.DERPMap)
logger := testutil.Logger(t) logger := testutil.Logger(t)
uut := tailnet.NewBasicDERPController(logger, fakeSetter(fs)) uut := tailnet.NewBasicDERPController(logger, nil, fakeSetter(fs))
fc := fakeDERPClient{ fc := fakeDERPClient{
ch: make(chan *tailcfg.DERPMap), ch: make(chan *tailcfg.DERPMap),
} }
@@ -609,7 +609,7 @@ func TestNewBasicDERPController_RecvErr(t *testing.T) {
t.Parallel() t.Parallel()
fs := make(chan *tailcfg.DERPMap) fs := make(chan *tailcfg.DERPMap)
logger := testutil.Logger(t) logger := testutil.Logger(t)
uut := tailnet.NewBasicDERPController(logger, fakeSetter(fs)) uut := tailnet.NewBasicDERPController(logger, nil, fakeSetter(fs))
expectedErr := xerrors.New("a bad thing happened") expectedErr := xerrors.New("a bad thing happened")
fc := fakeDERPClient{ fc := fakeDERPClient{
ch: make(chan *tailcfg.DERPMap), ch: make(chan *tailcfg.DERPMap),
@@ -1041,7 +1041,7 @@ func TestController_Disconnects(t *testing.T) {
// darwin can be slow sometimes. // darwin can be slow sometimes.
tailnet.WithGracefulTimeout(5*time.Second)) tailnet.WithGracefulTimeout(5*time.Second))
uut.CoordCtrl = tailnet.NewAgentCoordinationController(logger.Named("coord_ctrl"), fConn) uut.CoordCtrl = tailnet.NewAgentCoordinationController(logger.Named("coord_ctrl"), fConn)
uut.DERPCtrl = tailnet.NewBasicDERPController(logger.Named("derp_ctrl"), fConn) uut.DERPCtrl = tailnet.NewBasicDERPController(logger.Named("derp_ctrl"), nil, fConn)
uut.Run(ctx) uut.Run(ctx)
call := testutil.TryReceive(testCtx, t, fCoord.CoordinateCalls) call := testutil.TryReceive(testCtx, t, fCoord.CoordinateCalls)
@@ -1945,6 +1945,52 @@ func TestTunnelAllWorkspaceUpdatesController_HandleErrors(t *testing.T) {
} }
} }
func TestBasicDERPController_RewriteDERPMap(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
testDERPMap := &tailcfg.DERPMap{
Regions: map[int]*tailcfg.DERPRegion{
1: {
RegionID: 1,
},
},
}
// Ensure the fake rewriter works as expected.
rewriter := &fakeDERPMapRewriter{
ctx: ctx,
calls: make(chan rewriteDERPMapCall, 16),
}
rewriter.RewriteDERPMap(testDERPMap)
rewriteCall := testutil.TryReceive(ctx, t, rewriter.calls)
require.Same(t, testDERPMap, rewriteCall.derpMap)
derpClient := &fakeDERPClient{
ch: make(chan *tailcfg.DERPMap),
err: nil,
}
defer derpClient.Close()
derpSetter := &fakeDERPMapSetter{
ctx: ctx,
calls: make(chan *setDERPMapCall, 16),
}
derpCtrl := tailnet.NewBasicDERPController(logger, rewriter, derpSetter)
derpCtrl.New(derpClient)
// Simulate receiving a new DERP map from the server, which should be passed
// to the rewriter and setter.
testDERPMap = testDERPMap.Clone() // make a new pointer
derpClient.ch <- testDERPMap
rewriteCall = testutil.TryReceive(ctx, t, rewriter.calls)
require.Same(t, testDERPMap, rewriteCall.derpMap)
setterCall := testutil.TryReceive(ctx, t, derpSetter.calls)
require.Same(t, testDERPMap, setterCall.derpMap)
}
type fakeWorkspaceUpdatesController struct { type fakeWorkspaceUpdatesController struct {
ctx context.Context ctx context.Context
t testing.TB t testing.TB
@@ -2040,3 +2086,45 @@ type fakeCloser struct{}
func (fakeCloser) Close() error { func (fakeCloser) Close() error {
return nil return nil
} }
type fakeDERPMapRewriter struct {
ctx context.Context
calls chan rewriteDERPMapCall
}
var _ tailnet.DERPMapRewriter = &fakeDERPMapRewriter{}
type rewriteDERPMapCall struct {
derpMap *tailcfg.DERPMap
}
func (f *fakeDERPMapRewriter) RewriteDERPMap(derpMap *tailcfg.DERPMap) {
call := rewriteDERPMapCall{
derpMap: derpMap,
}
select {
case f.calls <- call:
case <-f.ctx.Done():
}
}
type fakeDERPMapSetter struct {
ctx context.Context
calls chan *setDERPMapCall
}
var _ tailnet.DERPMapSetter = &fakeDERPMapSetter{}
type setDERPMapCall struct {
derpMap *tailcfg.DERPMap
}
func (f *fakeDERPMapSetter) SetDERPMap(derpMap *tailcfg.DERPMap) {
call := &setDERPMapCall{
derpMap: derpMap,
}
select {
case <-f.ctx.Done():
case f.calls <- call:
}
}
+56
View File
@@ -5,10 +5,15 @@ import (
"context" "context"
"log" "log"
"net/http" "net/http"
"net/url"
"strconv"
"strings" "strings"
"sync" "sync"
"tailscale.com/derp" "tailscale.com/derp"
"tailscale.com/tailcfg"
"cdr.dev/slog"
"github.com/coder/websocket" "github.com/coder/websocket"
) )
@@ -70,3 +75,54 @@ func WithWebsocketSupport(s *derp.Server, base http.Handler) (http.Handler, func
mu.Unlock() mu.Unlock()
} }
} }
type DERPMapRewriter interface {
RewriteDERPMap(derpMap *tailcfg.DERPMap)
}
// RewriteDERPMapDefaultRelay rewrites the DERP map to use the given access URL
// as the "embedded relay" access URL. The passed derp map is modified in place.
//
// This is used by clients and agents to rewrite the default DERP relay to use
// their preferred access URL. Both of these clients can use a different access
// URL than the deployment has configured (with `--access-url`), so we need to
// accommodate that and respect the locally configured access URL.
//
// Note: passed context is only used for logging.
func RewriteDERPMapDefaultRelay(ctx context.Context, logger slog.Logger, derpMap *tailcfg.DERPMap, accessURL *url.URL) {
if derpMap == nil {
return
}
accessPort := 80
if accessURL.Scheme == "https" {
accessPort = 443
}
if accessURL.Port() != "" {
parsedAccessPort, err := strconv.Atoi(accessURL.Port())
if err != nil {
// This should never happen because URL.Port() returns the empty string
// if the port is not valid.
logger.Critical(ctx, "failed to parse URL port, using default port",
slog.F("port", accessURL.Port()),
slog.F("access_url", accessURL))
} else {
accessPort = parsedAccessPort
}
}
for _, region := range derpMap.Regions {
if !region.EmbeddedRelay {
continue
}
for _, node := range region.Nodes {
if node.STUNOnly {
continue
}
node.HostName = accessURL.Hostname()
node.DERPPort = accessPort
node.ForceHTTP = accessURL.Scheme == "http"
}
}
}
+20 -1
View File
@@ -78,6 +78,19 @@ type Options struct {
UpdateHandler tailnet.UpdatesHandler UpdateHandler tailnet.UpdatesHandler
} }
type derpMapRewriter struct {
logger slog.Logger
serverURL *url.URL
}
var _ tailnet.DERPMapRewriter = &derpMapRewriter{}
// RewriteDERPMap implements tailnet.DERPMapRewriter. See
// tailnet.RewriteDERPMapDefaultRelay for more details on why this is necessary.
func (d *derpMapRewriter) RewriteDERPMap(derpMap *tailcfg.DERPMap) {
tailnet.RewriteDERPMapDefaultRelay(context.Background(), d.logger, derpMap, d.serverURL)
}
func (*client) NewConn(initCtx context.Context, serverURL *url.URL, token string, options *Options) (vpnC Conn, err error) { func (*client) NewConn(initCtx context.Context, serverURL *url.URL, token string, options *Options) (vpnC Conn, err error) {
if options == nil { if options == nil {
options = &Options{} options = &Options{}
@@ -135,6 +148,12 @@ func (*client) NewConn(initCtx context.Context, serverURL *url.URL, token string
WorkspaceOwnerId: tailnet.UUIDToByteSlice(me.ID), WorkspaceOwnerId: tailnet.UUIDToByteSlice(me.ID),
})) }))
derpMapRewriter := &derpMapRewriter{
logger: options.Logger,
serverURL: serverURL,
}
derpMapRewriter.RewriteDERPMap(connInfo.DERPMap)
clonedHeaders := headers.Clone() clonedHeaders := headers.Clone()
ip := tailnet.CoderServicePrefix.RandomAddr() ip := tailnet.CoderServicePrefix.RandomAddr()
conn, err := tailnet.NewConn(&tailnet.Options{ conn, err := tailnet.NewConn(&tailnet.Options{
@@ -164,7 +183,7 @@ func (*client) NewConn(initCtx context.Context, serverURL *url.URL, token string
coordCtrl := tailnet.NewTunnelSrcCoordController(options.Logger, conn) coordCtrl := tailnet.NewTunnelSrcCoordController(options.Logger, conn)
controller.ResumeTokenCtrl = tailnet.NewBasicResumeTokenController(options.Logger, clk) controller.ResumeTokenCtrl = tailnet.NewBasicResumeTokenController(options.Logger, clk)
controller.CoordCtrl = coordCtrl controller.CoordCtrl = coordCtrl
controller.DERPCtrl = tailnet.NewBasicDERPController(options.Logger, conn) controller.DERPCtrl = tailnet.NewBasicDERPController(options.Logger, derpMapRewriter, conn)
updatesCtrl := tailnet.NewTunnelAllWorkspaceUpdatesController( updatesCtrl := tailnet.NewTunnelAllWorkspaceUpdatesController(
options.Logger, options.Logger,
coordCtrl, coordCtrl,
+11 -6
View File
@@ -43,14 +43,19 @@ func TestClient_WorkspaceUpdates(t *testing.T) {
hostnames []string hostnames []string
}{ }{
{ {
name: "empty", name: "empty",
agentConnectionInfo: workspacesdk.AgentConnectionInfo{}, agentConnectionInfo: workspacesdk.AgentConnectionInfo{
hostnames: []string{"wrk.coder.", "agnt.wrk.me.coder.", "agnt.wrk.rootbeer.coder."}, DERPMap: &tailcfg.DERPMap{},
},
hostnames: []string{"wrk.coder.", "agnt.wrk.me.coder.", "agnt.wrk.rootbeer.coder."},
}, },
{ {
name: "suffix", name: "suffix",
agentConnectionInfo: workspacesdk.AgentConnectionInfo{HostnameSuffix: "float"}, agentConnectionInfo: workspacesdk.AgentConnectionInfo{
hostnames: []string{"wrk.float.", "agnt.wrk.me.float.", "agnt.wrk.rootbeer.float."}, HostnameSuffix: "float",
DERPMap: &tailcfg.DERPMap{},
},
hostnames: []string{"wrk.float.", "agnt.wrk.me.float.", "agnt.wrk.rootbeer.float."},
}, },
} }
for _, tc := range testCases { for _, tc := range testCases {