diff --git a/codersdk/workspacesdk/workspacesdk.go b/codersdk/workspacesdk/workspacesdk.go index 82ae7904f8046..df851e5ac31e9 100644 --- a/codersdk/workspacesdk/workspacesdk.go +++ b/codersdk/workspacesdk/workspacesdk.go @@ -143,7 +143,7 @@ type AgentConnectionInfo struct { DERPMap *tailcfg.DERPMap `json:"derp_map"` DERPForceWebSockets bool `json:"derp_force_websockets"` DisableDirectConnections bool `json:"disable_direct_connections"` - HostnameSuffix string `json:"hostname_suffix"` + HostnameSuffix string `json:"hostname_suffix,omitempty"` } func (c *Client) AgentConnectionInfoGeneric(ctx context.Context) (AgentConnectionInfo, error) { diff --git a/tailnet/conn.go b/tailnet/conn.go index 89b3b7d483d0c..0a1ee1977e98b 100644 --- a/tailnet/conn.go +++ b/tailnet/conn.go @@ -357,9 +357,7 @@ func NewConn(options *Options) (conn *Conn, err error) { // A FQDN to be mapped to `tsaddr.CoderServiceIPv6`. This address can be used // when you want to know if Coder Connect is running, but are not trying to // connect to a specific known workspace. -const IsCoderConnectEnabledFQDNString = "is.coder--connect--enabled--right--now.coder." - -var IsCoderConnectEnabledFQDN, _ = dnsname.ToFQDN(IsCoderConnectEnabledFQDNString) +const IsCoderConnectEnabledFmtString = "is.coder--connect--enabled--right--now.%s." type ServicePrefix [6]byte diff --git a/tailnet/controllers.go b/tailnet/controllers.go index 7a077ffabfaa0..b5f37311a0f71 100644 --- a/tailnet/controllers.go +++ b/tailnet/controllers.go @@ -864,11 +864,12 @@ func (r *basicResumeTokenRefresher) refresh() { } type TunnelAllWorkspaceUpdatesController struct { - coordCtrl *TunnelSrcCoordController - dnsHostSetter DNSHostsSetter - updateHandler UpdatesHandler - ownerUsername string - logger slog.Logger + coordCtrl *TunnelSrcCoordController + dnsHostSetter DNSHostsSetter + dnsNameOptions DNSNameOptions + updateHandler UpdatesHandler + ownerUsername string + logger slog.Logger mu sync.Mutex updater *tunnelUpdater @@ -883,12 +884,16 @@ type Workspace struct { agents map[uuid.UUID]*Agent } +type DNSNameOptions struct { + Suffix string +} + // updateDNSNames updates the DNS names for all agents in the workspace. // DNS hosts must be all lowercase, or the resolver won't be able to find them. // Usernames are globally unique & case-insensitive. // Workspace names are unique per-user & case-insensitive. // Agent names are unique per-workspace & case-insensitive. -func (w *Workspace) updateDNSNames() error { +func (w *Workspace) updateDNSNames(options DNSNameOptions) error { wsName := strings.ToLower(w.Name) username := strings.ToLower(w.ownerUsername) for id, a := range w.agents { @@ -896,24 +901,22 @@ func (w *Workspace) updateDNSNames() error { names := make(map[dnsname.FQDN][]netip.Addr) // TODO: technically, DNS labels cannot start with numbers, but the rules are often not // strictly enforced. - fqdn, err := dnsname.ToFQDN(fmt.Sprintf("%s.%s.me.coder.", agentName, wsName)) + fqdn, err := dnsname.ToFQDN(fmt.Sprintf("%s.%s.me.%s.", agentName, wsName, options.Suffix)) if err != nil { return err } names[fqdn] = []netip.Addr{CoderServicePrefix.AddrFromUUID(a.ID)} - fqdn, err = dnsname.ToFQDN(fmt.Sprintf("%s.%s.%s.coder.", agentName, wsName, username)) + fqdn, err = dnsname.ToFQDN(fmt.Sprintf("%s.%s.%s.%s.", agentName, wsName, username, options.Suffix)) if err != nil { return err } names[fqdn] = []netip.Addr{CoderServicePrefix.AddrFromUUID(a.ID)} if len(w.agents) == 1 { - fqdn, err := dnsname.ToFQDN(fmt.Sprintf("%s.coder.", wsName)) + fqdn, err = dnsname.ToFQDN(fmt.Sprintf("%s.%s.", wsName, options.Suffix)) if err != nil { return err } - for _, a := range w.agents { - names[fqdn] = []netip.Addr{CoderServicePrefix.AddrFromUUID(a.ID)} - } + names[fqdn] = []netip.Addr{CoderServicePrefix.AddrFromUUID(a.ID)} } a.Hosts = names w.agents[id] = a @@ -950,6 +953,7 @@ func (t *TunnelAllWorkspaceUpdatesController) New(client WorkspaceUpdatesClient) logger: t.logger, coordCtrl: t.coordCtrl, dnsHostsSetter: t.dnsHostSetter, + dnsNameOptions: t.dnsNameOptions, updateHandler: t.updateHandler, ownerUsername: t.ownerUsername, recvLoopDone: make(chan struct{}), @@ -996,6 +1000,7 @@ type tunnelUpdater struct { updateHandler UpdatesHandler ownerUsername string recvLoopDone chan struct{} + dnsNameOptions DNSNameOptions sync.Mutex workspaces map[uuid.UUID]*Workspace @@ -1250,7 +1255,7 @@ func (t *tunnelUpdater) allAgentIDsLocked() []uuid.UUID { func (t *tunnelUpdater) updateDNSNamesLocked() map[dnsname.FQDN][]netip.Addr { names := make(map[dnsname.FQDN][]netip.Addr) for _, w := range t.workspaces { - err := w.updateDNSNames() + err := w.updateDNSNames(t.dnsNameOptions) if err != nil { // This should never happen in production, because converting the FQDN only fails // if names are too long, and we put strict length limits on agent, workspace, and user @@ -1258,6 +1263,7 @@ func (t *tunnelUpdater) updateDNSNamesLocked() map[dnsname.FQDN][]netip.Addr { t.logger.Critical(context.Background(), "failed to include DNS name(s)", slog.F("workspace_id", w.ID), + slog.F("suffix", t.dnsNameOptions.Suffix), slog.Error(err)) } for _, a := range w.agents { @@ -1266,7 +1272,13 @@ func (t *tunnelUpdater) updateDNSNamesLocked() map[dnsname.FQDN][]netip.Addr { } } } - names[IsCoderConnectEnabledFQDN] = []netip.Addr{tsaddr.CoderServiceIPv6()} + isCoderConnectEnabledFQDN, err := dnsname.ToFQDN(fmt.Sprintf(IsCoderConnectEnabledFmtString, t.dnsNameOptions.Suffix)) + if err != nil { + t.logger.Critical(context.Background(), + "failed to include Coder Connect enabled DNS name", slog.F("suffix", t.dnsNameOptions.Suffix)) + } else { + names[isCoderConnectEnabledFQDN] = []netip.Addr{tsaddr.CoderServiceIPv6()} + } return names } @@ -1274,10 +1286,11 @@ type TunnelAllOption func(t *TunnelAllWorkspaceUpdatesController) // WithDNS configures the tunnelAllWorkspaceUpdatesController to set DNS names for all workspaces // and agents it learns about. -func WithDNS(d DNSHostsSetter, ownerUsername string) TunnelAllOption { +func WithDNS(d DNSHostsSetter, ownerUsername string, options DNSNameOptions) TunnelAllOption { return func(t *TunnelAllWorkspaceUpdatesController) { t.dnsHostSetter = d t.ownerUsername = ownerUsername + t.dnsNameOptions = options } } @@ -1293,7 +1306,11 @@ func WithHandler(h UpdatesHandler) TunnelAllOption { func NewTunnelAllWorkspaceUpdatesController( logger slog.Logger, c *TunnelSrcCoordController, opts ...TunnelAllOption, ) *TunnelAllWorkspaceUpdatesController { - t := &TunnelAllWorkspaceUpdatesController{logger: logger, coordCtrl: c} + t := &TunnelAllWorkspaceUpdatesController{ + logger: logger, + coordCtrl: c, + dnsNameOptions: DNSNameOptions{"coder"}, + } for _, opt := range opts { opt(t) } diff --git a/tailnet/controllers_test.go b/tailnet/controllers_test.go index 3cfa47e3adca2..089d1b1e82a29 100644 --- a/tailnet/controllers_test.go +++ b/tailnet/controllers_test.go @@ -1522,7 +1522,7 @@ func TestTunnelAllWorkspaceUpdatesController_Initial(t *testing.T) { fUH := newFakeUpdateHandler(ctx, t) fDNS := newFakeDNSSetter(ctx, t) coordC, updateC, updateCtrl := setupConnectedAllWorkspaceUpdatesController(ctx, t, logger, - tailnet.WithDNS(fDNS, "testy"), + tailnet.WithDNS(fDNS, "testy", tailnet.DNSNameOptions{Suffix: "mctest"}), tailnet.WithHandler(fUH), ) @@ -1562,16 +1562,19 @@ func TestTunnelAllWorkspaceUpdatesController_Initial(t *testing.T) { w2a1IP := netip.MustParseAddr("fd60:627a:a42b:0201::") w2a2IP := netip.MustParseAddr("fd60:627a:a42b:0202::") + expectedCoderConnectFQDN, err := dnsname.ToFQDN(fmt.Sprintf(tailnet.IsCoderConnectEnabledFmtString, "mctest")) + require.NoError(t, err) + // Also triggers setting DNS hosts expectedDNS := map[dnsname.FQDN][]netip.Addr{ - "w1a1.w1.me.coder.": {ws1a1IP}, - "w2a1.w2.me.coder.": {w2a1IP}, - "w2a2.w2.me.coder.": {w2a2IP}, - "w1a1.w1.testy.coder.": {ws1a1IP}, - "w2a1.w2.testy.coder.": {w2a1IP}, - "w2a2.w2.testy.coder.": {w2a2IP}, - "w1.coder.": {ws1a1IP}, - tailnet.IsCoderConnectEnabledFQDNString: {tsaddr.CoderServiceIPv6()}, + "w1a1.w1.me.mctest.": {ws1a1IP}, + "w2a1.w2.me.mctest.": {w2a1IP}, + "w2a2.w2.me.mctest.": {w2a2IP}, + "w1a1.w1.testy.mctest.": {ws1a1IP}, + "w2a1.w2.testy.mctest.": {w2a1IP}, + "w2a2.w2.testy.mctest.": {w2a2IP}, + "w1.mctest.": {ws1a1IP}, + expectedCoderConnectFQDN: {tsaddr.CoderServiceIPv6()}, } dnsCall := testutil.RequireRecvCtx(ctx, t, fDNS.calls) require.Equal(t, expectedDNS, dnsCall.hosts) @@ -1586,23 +1589,23 @@ func TestTunnelAllWorkspaceUpdatesController_Initial(t *testing.T) { { ID: w1a1ID, Name: "w1a1", WorkspaceID: w1ID, Hosts: map[dnsname.FQDN][]netip.Addr{ - "w1.coder.": {ws1a1IP}, - "w1a1.w1.me.coder.": {ws1a1IP}, - "w1a1.w1.testy.coder.": {ws1a1IP}, + "w1.mctest.": {ws1a1IP}, + "w1a1.w1.me.mctest.": {ws1a1IP}, + "w1a1.w1.testy.mctest.": {ws1a1IP}, }, }, { ID: w2a1ID, Name: "w2a1", WorkspaceID: w2ID, Hosts: map[dnsname.FQDN][]netip.Addr{ - "w2a1.w2.me.coder.": {w2a1IP}, - "w2a1.w2.testy.coder.": {w2a1IP}, + "w2a1.w2.me.mctest.": {w2a1IP}, + "w2a1.w2.testy.mctest.": {w2a1IP}, }, }, { ID: w2a2ID, Name: "w2a2", WorkspaceID: w2ID, Hosts: map[dnsname.FQDN][]netip.Addr{ - "w2a2.w2.me.coder.": {w2a2IP}, - "w2a2.w2.testy.coder.": {w2a2IP}, + "w2a2.w2.me.mctest.": {w2a2IP}, + "w2a2.w2.testy.mctest.": {w2a2IP}, }, }, }, @@ -1634,7 +1637,7 @@ func TestTunnelAllWorkspaceUpdatesController_DeleteAgent(t *testing.T) { fUH := newFakeUpdateHandler(ctx, t) fDNS := newFakeDNSSetter(ctx, t) coordC, updateC, updateCtrl := setupConnectedAllWorkspaceUpdatesController(ctx, t, logger, - tailnet.WithDNS(fDNS, "testy"), + tailnet.WithDNS(fDNS, "testy", tailnet.DNSNameOptions{Suffix: "coder"}), tailnet.WithHandler(fUH), ) @@ -1661,12 +1664,15 @@ func TestTunnelAllWorkspaceUpdatesController_DeleteAgent(t *testing.T) { require.Equal(t, w1a1ID[:], coordCall.req.GetAddTunnel().GetId()) testutil.RequireSendCtx(ctx, t, coordCall.err, nil) + expectedCoderConnectFQDN, err := dnsname.ToFQDN(fmt.Sprintf(tailnet.IsCoderConnectEnabledFmtString, "coder")) + require.NoError(t, err) + // DNS for w1a1 expectedDNS := map[dnsname.FQDN][]netip.Addr{ - "w1a1.w1.testy.coder.": {ws1a1IP}, - "w1a1.w1.me.coder.": {ws1a1IP}, - "w1.coder.": {ws1a1IP}, - tailnet.IsCoderConnectEnabledFQDNString: {tsaddr.CoderServiceIPv6()}, + "w1a1.w1.testy.coder.": {ws1a1IP}, + "w1a1.w1.me.coder.": {ws1a1IP}, + "w1.coder.": {ws1a1IP}, + expectedCoderConnectFQDN: {tsaddr.CoderServiceIPv6()}, } dnsCall := testutil.RequireRecvCtx(ctx, t, fDNS.calls) require.Equal(t, expectedDNS, dnsCall.hosts) @@ -1719,10 +1725,10 @@ func TestTunnelAllWorkspaceUpdatesController_DeleteAgent(t *testing.T) { // DNS contains only w1a2 expectedDNS = map[dnsname.FQDN][]netip.Addr{ - "w1a2.w1.testy.coder.": {ws1a2IP}, - "w1a2.w1.me.coder.": {ws1a2IP}, - "w1.coder.": {ws1a2IP}, - tailnet.IsCoderConnectEnabledFQDNString: {tsaddr.CoderServiceIPv6()}, + "w1a2.w1.testy.coder.": {ws1a2IP}, + "w1a2.w1.me.coder.": {ws1a2IP}, + "w1.coder.": {ws1a2IP}, + expectedCoderConnectFQDN: {tsaddr.CoderServiceIPv6()}, } dnsCall = testutil.RequireRecvCtx(ctx, t, fDNS.calls) require.Equal(t, expectedDNS, dnsCall.hosts) @@ -1779,7 +1785,7 @@ func TestTunnelAllWorkspaceUpdatesController_DNSError(t *testing.T) { fConn := &fakeCoordinatee{} tsc := tailnet.NewTunnelSrcCoordController(logger, fConn) uut := tailnet.NewTunnelAllWorkspaceUpdatesController(logger, tsc, - tailnet.WithDNS(fDNS, "testy"), + tailnet.WithDNS(fDNS, "testy", tailnet.DNSNameOptions{Suffix: "coder"}), ) updateC := newFakeWorkspaceUpdateClient(ctx, t) @@ -1800,12 +1806,15 @@ func TestTunnelAllWorkspaceUpdatesController_DNSError(t *testing.T) { upRecvCall := testutil.RequireRecvCtx(ctx, t, updateC.recv) testutil.RequireSendCtx(ctx, t, upRecvCall.resp, initUp) + expectedCoderConnectFQDN, err := dnsname.ToFQDN(fmt.Sprintf(tailnet.IsCoderConnectEnabledFmtString, "coder")) + require.NoError(t, err) + // DNS for w1a1 expectedDNS := map[dnsname.FQDN][]netip.Addr{ - "w1a1.w1.me.coder.": {ws1a1IP}, - "w1a1.w1.testy.coder.": {ws1a1IP}, - "w1.coder.": {ws1a1IP}, - tailnet.IsCoderConnectEnabledFQDNString: {tsaddr.CoderServiceIPv6()}, + "w1a1.w1.me.coder.": {ws1a1IP}, + "w1a1.w1.testy.coder.": {ws1a1IP}, + "w1.coder.": {ws1a1IP}, + expectedCoderConnectFQDN: {tsaddr.CoderServiceIPv6()}, } dnsCall := testutil.RequireRecvCtx(ctx, t, fDNS.calls) require.Equal(t, expectedDNS, dnsCall.hosts) @@ -1816,7 +1825,7 @@ func TestTunnelAllWorkspaceUpdatesController_DNSError(t *testing.T) { testutil.RequireSendCtx(ctx, t, closeCall, io.EOF) // error should be our initial DNS error - err := testutil.RequireRecvCtx(ctx, t, updateCW.Wait()) + err = testutil.RequireRecvCtx(ctx, t, updateCW.Wait()) require.ErrorIs(t, err, dnsError) } diff --git a/vpn/client.go b/vpn/client.go index 882197165e9ea..85e0d45c3d6f8 100644 --- a/vpn/client.go +++ b/vpn/client.go @@ -107,6 +107,11 @@ func (*client) NewConn(initCtx context.Context, serverURL *url.URL, token string if err != nil { return nil, xerrors.Errorf("get connection info: %w", err) } + // default to DNS suffix of "coder" if the server hasn't set it (might be too old). + dnsNameOptions := tailnet.DNSNameOptions{Suffix: "coder"} + if connInfo.HostnameSuffix != "" { + dnsNameOptions.Suffix = connInfo.HostnameSuffix + } headers.Set(codersdk.SessionTokenHeader, token) dialer := workspacesdk.NewWebsocketDialer(options.Logger, rpcURL, &websocket.DialOptions{ @@ -148,7 +153,7 @@ func (*client) NewConn(initCtx context.Context, serverURL *url.URL, token string updatesCtrl := tailnet.NewTunnelAllWorkspaceUpdatesController( options.Logger, coordCtrl, - tailnet.WithDNS(conn, me.Username), + tailnet.WithDNS(conn, me.Username, dnsNameOptions), tailnet.WithHandler(options.UpdateHandler), ) controller.WorkspaceUpdatesCtrl = updatesCtrl diff --git a/vpn/client_test.go b/vpn/client_test.go index a1166eeaabe70..41602d1ffa79f 100644 --- a/vpn/client_test.go +++ b/vpn/client_test.go @@ -3,11 +3,14 @@ package vpn_test import ( "net/http" "net/http/httptest" + "net/netip" "net/url" "sync/atomic" "testing" "time" + "tailscale.com/util/dnsname" + "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -29,136 +32,180 @@ import ( func TestClient_WorkspaceUpdates(t *testing.T) { t.Parallel() - ctx := testutil.Context(t, testutil.WaitShort) - logger := testutil.Logger(t) - userID := uuid.UUID{1} wsID := uuid.UUID{2} peerID := uuid.UUID{3} - - fCoord := tailnettest.NewFakeCoordinator() - var coord tailnet.Coordinator = fCoord - coordPtr := atomic.Pointer[tailnet.Coordinator]{} - coordPtr.Store(&coord) - ctrl := gomock.NewController(t) - mProvider := tailnettest.NewMockWorkspaceUpdatesProvider(ctrl) - - mSub := tailnettest.NewMockSubscription(ctrl) - outUpdateCh := make(chan *proto.WorkspaceUpdate, 1) - inUpdateCh := make(chan tailnet.WorkspaceUpdate, 1) - mProvider.EXPECT().Subscribe(gomock.Any(), userID).Times(1).Return(mSub, nil) - mSub.EXPECT().Updates().MinTimes(1).Return(outUpdateCh) - mSub.EXPECT().Close().Times(1).Return(nil) - - svc, err := tailnet.NewClientService(tailnet.ClientServiceOptions{ - Logger: logger, - CoordPtr: &coordPtr, - DERPMapUpdateFrequency: time.Hour, - DERPMapFn: func() *tailcfg.DERPMap { return &tailcfg.DERPMap{} }, - WorkspaceUpdatesProvider: mProvider, - ResumeTokenProvider: tailnet.NewInsecureTestResumeTokenProvider(), - }) - require.NoError(t, err) - - user := make(chan struct{}) - connInfo := make(chan struct{}) - serveErrCh := make(chan error) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/users/me": - httpapi.Write(ctx, w, http.StatusOK, codersdk.User{ - ReducedUser: codersdk.ReducedUser{ - MinimalUser: codersdk.MinimalUser{ - ID: userID, + agentID := uuid.UUID{4} + + testCases := []struct { + name string + agentConnectionInfo workspacesdk.AgentConnectionInfo + hostnames []string + }{ + { + name: "empty", + agentConnectionInfo: workspacesdk.AgentConnectionInfo{}, + hostnames: []string{"wrk.coder.", "agnt.wrk.me.coder.", "agnt.wrk.rootbeer.coder."}, + }, + { + name: "suffix", + agentConnectionInfo: workspacesdk.AgentConnectionInfo{HostnameSuffix: "float"}, + hostnames: []string{"wrk.float.", "agnt.wrk.me.float.", "agnt.wrk.rootbeer.float."}, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + logger := testutil.Logger(t) + + fCoord := tailnettest.NewFakeCoordinator() + var coord tailnet.Coordinator = fCoord + coordPtr := atomic.Pointer[tailnet.Coordinator]{} + coordPtr.Store(&coord) + ctrl := gomock.NewController(t) + mProvider := tailnettest.NewMockWorkspaceUpdatesProvider(ctrl) + + mSub := tailnettest.NewMockSubscription(ctrl) + outUpdateCh := make(chan *proto.WorkspaceUpdate, 1) + inUpdateCh := make(chan tailnet.WorkspaceUpdate, 1) + mProvider.EXPECT().Subscribe(gomock.Any(), userID).Times(1).Return(mSub, nil) + mSub.EXPECT().Updates().MinTimes(1).Return(outUpdateCh) + mSub.EXPECT().Close().Times(1).Return(nil) + + svc, err := tailnet.NewClientService(tailnet.ClientServiceOptions{ + Logger: logger, + CoordPtr: &coordPtr, + DERPMapUpdateFrequency: time.Hour, + DERPMapFn: func() *tailcfg.DERPMap { return &tailcfg.DERPMap{} }, + WorkspaceUpdatesProvider: mProvider, + ResumeTokenProvider: tailnet.NewInsecureTestResumeTokenProvider(), + }) + require.NoError(t, err) + + user := make(chan struct{}) + connInfo := make(chan struct{}) + serveErrCh := make(chan error) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v2/users/me": + httpapi.Write(ctx, w, http.StatusOK, codersdk.User{ + ReducedUser: codersdk.ReducedUser{ + MinimalUser: codersdk.MinimalUser{ + ID: userID, + Username: "rootbeer", + }, + }, + }) + user <- struct{}{} + + case "/api/v2/workspaceagents/connection": + httpapi.Write(ctx, w, http.StatusOK, tc.agentConnectionInfo) + connInfo <- struct{}{} + + case "/api/v2/tailnet": + // need 2.3 for WorkspaceUpdates RPC + cVer := r.URL.Query().Get("version") + assert.Equal(t, "2.3", cVer) + + sws, err := websocket.Accept(w, r, nil) + if !assert.NoError(t, err) { + return + } + wsCtx, nc := codersdk.WebsocketNetConn(ctx, sws, websocket.MessageBinary) + serveErrCh <- svc.ServeConnV2(wsCtx, nc, tailnet.StreamID{ + Name: "client", + ID: peerID, + // Auth can be nil as we use a mock update provider + Auth: tailnet.ClientUserCoordinateeAuth{ + Auth: nil, + }, + }) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + + svrURL, err := url.Parse(server.URL) + require.NoError(t, err) + connErrCh := make(chan error) + connCh := make(chan vpn.Conn) + go func() { + conn, err := vpn.NewClient().NewConn(ctx, svrURL, "fakeToken", &vpn.Options{ + UpdateHandler: updateHandler(func(wu tailnet.WorkspaceUpdate) error { + inUpdateCh <- wu + return nil + }), + DNSConfigurator: &noopConfigurator{}, + }) + connErrCh <- err + connCh <- conn + }() + testutil.RequireRecvCtx(ctx, t, user) + testutil.RequireRecvCtx(ctx, t, connInfo) + err = testutil.RequireRecvCtx(ctx, t, connErrCh) + require.NoError(t, err) + conn := testutil.RequireRecvCtx(ctx, t, connCh) + + // Send a workspace update + update := &proto.WorkspaceUpdate{ + UpsertedWorkspaces: []*proto.Workspace{ + { + Id: wsID[:], + Name: "wrk", }, }, - }) - user <- struct{}{} - - case "/api/v2/workspaceagents/connection": - httpapi.Write(ctx, w, http.StatusOK, workspacesdk.AgentConnectionInfo{ - DisableDirectConnections: false, - }) - connInfo <- struct{}{} + UpsertedAgents: []*proto.Agent{ + { + Id: agentID[:], + Name: "agnt", + WorkspaceId: wsID[:], + }, + }, + } + testutil.RequireSendCtx(ctx, t, outUpdateCh, update) - case "/api/v2/tailnet": - // need 2.3 for WorkspaceUpdates RPC - cVer := r.URL.Query().Get("version") - assert.Equal(t, "2.3", cVer) + // It'll be received by the update handler + recvUpdate := testutil.RequireRecvCtx(ctx, t, inUpdateCh) + require.Len(t, recvUpdate.UpsertedWorkspaces, 1) + require.Equal(t, wsID, recvUpdate.UpsertedWorkspaces[0].ID) + require.Len(t, recvUpdate.UpsertedAgents, 1) - sws, err := websocket.Accept(w, r, nil) - if !assert.NoError(t, err) { - return + expectedHosts := map[dnsname.FQDN][]netip.Addr{} + for _, name := range tc.hostnames { + expectedHosts[dnsname.FQDN(name)] = []netip.Addr{tailnet.CoderServicePrefix.AddrFromUUID(agentID)} } - wsCtx, nc := codersdk.WebsocketNetConn(ctx, sws, websocket.MessageBinary) - serveErrCh <- svc.ServeConnV2(wsCtx, nc, tailnet.StreamID{ - Name: "client", - ID: peerID, - // Auth can be nil as we use a mock update provider - Auth: tailnet.ClientUserCoordinateeAuth{ - Auth: nil, + + // And be reflected on the Conn's state + state, err := conn.CurrentWorkspaceState() + require.NoError(t, err) + require.Equal(t, tailnet.WorkspaceUpdate{ + UpsertedWorkspaces: []*tailnet.Workspace{ + { + ID: wsID, + Name: "wrk", + }, }, - }) - default: - http.NotFound(w, r) - } - })) - t.Cleanup(server.Close) - - svrURL, err := url.Parse(server.URL) - require.NoError(t, err) - connErrCh := make(chan error) - connCh := make(chan vpn.Conn) - go func() { - conn, err := vpn.NewClient().NewConn(ctx, svrURL, "fakeToken", &vpn.Options{ - UpdateHandler: updateHandler(func(wu tailnet.WorkspaceUpdate) error { - inUpdateCh <- wu - return nil - }), - DNSConfigurator: &noopConfigurator{}, + UpsertedAgents: []*tailnet.Agent{ + { + ID: agentID, + Name: "agnt", + WorkspaceID: wsID, + Hosts: expectedHosts, + }, + }, + DeletedWorkspaces: []*tailnet.Workspace{}, + DeletedAgents: []*tailnet.Agent{}, + }, state) + + // Close the conn + conn.Close() + err = testutil.RequireRecvCtx(ctx, t, serveErrCh) + require.NoError(t, err) }) - connErrCh <- err - connCh <- conn - }() - testutil.RequireRecvCtx(ctx, t, user) - testutil.RequireRecvCtx(ctx, t, connInfo) - err = testutil.RequireRecvCtx(ctx, t, connErrCh) - require.NoError(t, err) - conn := testutil.RequireRecvCtx(ctx, t, connCh) - - // Send a workspace update - update := &proto.WorkspaceUpdate{ - UpsertedWorkspaces: []*proto.Workspace{ - { - Id: wsID[:], - }, - }, } - testutil.RequireSendCtx(ctx, t, outUpdateCh, update) - - // It'll be received by the update handler - recvUpdate := testutil.RequireRecvCtx(ctx, t, inUpdateCh) - require.Len(t, recvUpdate.UpsertedWorkspaces, 1) - require.Equal(t, wsID, recvUpdate.UpsertedWorkspaces[0].ID) - - // And be reflected on the Conn's state - state, err := conn.CurrentWorkspaceState() - require.NoError(t, err) - require.Equal(t, tailnet.WorkspaceUpdate{ - UpsertedWorkspaces: []*tailnet.Workspace{ - { - ID: wsID, - }, - }, - UpsertedAgents: []*tailnet.Agent{}, - DeletedWorkspaces: []*tailnet.Workspace{}, - DeletedAgents: []*tailnet.Agent{}, - }, state) - - // Close the conn - conn.Close() - err = testutil.RequireRecvCtx(ctx, t, serveErrCh) - require.NoError(t, err) } type updateHandler func(tailnet.WorkspaceUpdate) error