From 6de5ce2ae3bc586bcc66436bdb24533961e8f547 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Fri, 27 Sep 2024 08:33:59 +0000 Subject: [PATCH 1/7] feat: add WorkspaceUpdates rpc --- cli/server.go | 7 + coderd/apidoc/docs.go | 19 + coderd/apidoc/swagger.json | 17 + coderd/coderd.go | 16 +- coderd/coderdtest/coderdtest.go | 12 +- coderd/workspaceagents.go | 122 ++- coderd/workspaceagents_test.go | 138 ++++ coderd/workspacebuilds.go | 36 +- coderd/workspaceupdates.go | 299 ++++++++ coderd/workspaceupdates_test.go | 313 ++++++++ codersdk/provisionerdaemons.go | 34 + .../workspacesdk/connector_internal_test.go | 5 + docs/reference/api/agents.md | 20 + tailnet/convert.go | 28 + tailnet/proto/tailnet.pb.go | 719 ++++++++++++++---- tailnet/proto/tailnet.proto | 36 + tailnet/proto/tailnet_drpc.pb.go | 70 +- tailnet/service.go | 121 ++- tailnet/tunnel.go | 37 + 19 files changed, 1812 insertions(+), 237 deletions(-) create mode 100644 coderd/workspaceupdates.go create mode 100644 coderd/workspaceupdates_test.go diff --git a/cli/server.go b/cli/server.go index c053d8dc7ef02..4abff00bb89f3 100644 --- a/cli/server.go +++ b/cli/server.go @@ -728,6 +728,13 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. options.Database = dbmetrics.NewDBMetrics(options.Database, options.Logger, options.PrometheusRegistry) } + wsUpdates, err := coderd.NewUpdatesProvider(logger.Named("workspace_updates"), options.Database, options.Pubsub) + if err != nil { + return xerrors.Errorf("create workspace updates provider: %w", err) + } + options.WorkspaceUpdatesProvider = wsUpdates + defer wsUpdates.Stop() + var deploymentID string err = options.Database.InTx(func(tx database.Store) error { // This will block until the lock is acquired, and will be diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 372303c320a34..5a235d677d82b 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -5232,6 +5232,25 @@ const docTemplate = `{ } } }, + "/users/me/tailnet": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "tags": [ + "Agents" + ], + "summary": "Coordinate multiple workspace agents", + "operationId": "coordinate-multiple-workspace-agents", + "responses": { + "101": { + "description": "Switching Protocols" + } + } + } + }, "/users/oauth2/github/callback": { "get": { "security": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index db8b53e966bf4..99e0a8326093a 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -4614,6 +4614,23 @@ } } }, + "/users/me/tailnet": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "tags": ["Agents"], + "summary": "Coordinate multiple workspace agents", + "operationId": "coordinate-multiple-workspace-agents", + "responses": { + "101": { + "description": "Switching Protocols" + } + } + } + }, "/users/oauth2/github/callback": { "get": { "security": [ diff --git a/coderd/coderd.go b/coderd/coderd.go index 70101b7020890..df89eef289fb5 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -227,6 +227,8 @@ type Options struct { WorkspaceAppsStatsCollectorOptions workspaceapps.StatsCollectorOptions + WorkspaceUpdatesProvider tailnet.WorkspaceUpdatesProvider + // This janky function is used in telemetry to parse fields out of the raw // JWT. It needs to be passed through like this because license parsing is // under the enterprise license, and can't be imported into AGPL. @@ -652,12 +654,13 @@ func New(options *Options) *API { panic("CoordinatorResumeTokenProvider is nil") } api.TailnetClientService, err = tailnet.NewClientService(tailnet.ClientServiceOptions{ - Logger: api.Logger.Named("tailnetclient"), - CoordPtr: &api.TailnetCoordinator, - DERPMapUpdateFrequency: api.Options.DERPMapUpdateFrequency, - DERPMapFn: api.DERPMap, - NetworkTelemetryHandler: api.NetworkTelemetryBatcher.Handler, - ResumeTokenProvider: api.Options.CoordinatorResumeTokenProvider, + Logger: api.Logger.Named("tailnetclient"), + CoordPtr: &api.TailnetCoordinator, + DERPMapUpdateFrequency: api.Options.DERPMapUpdateFrequency, + DERPMapFn: api.DERPMap, + NetworkTelemetryHandler: api.NetworkTelemetryBatcher.Handler, + ResumeTokenProvider: api.Options.CoordinatorResumeTokenProvider, + WorkspaceUpdatesProvider: api.Options.WorkspaceUpdatesProvider, }) if err != nil { api.Logger.Fatal(context.Background(), "failed to initialize tailnet client service", slog.Error(err)) @@ -1070,6 +1073,7 @@ func New(options *Options) *API { r.Route("/roles", func(r chi.Router) { r.Get("/", api.AssignableSiteRoles) }) + r.Get("/me/tailnet", api.tailnet) r.Route("/{user}", func(r chi.Router) { r.Use(httpmw.ExtractUserParam(options.Database)) r.Post("/convert-login", api.postConvertLoginType) diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index f3868bf14d54b..69a2af1cce2cc 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -159,10 +159,12 @@ type Options struct { DatabaseRolluper *dbrollup.Rolluper WorkspaceUsageTrackerFlush chan int WorkspaceUsageTrackerTick chan time.Time - NotificationsEnqueuer notifications.Enqueuer APIKeyEncryptionCache cryptokeys.EncryptionKeycache OIDCConvertKeyCache cryptokeys.SigningKeycache Clock quartz.Clock + NotificationsEnqueuer notifications.Enqueuer + + WorkspaceUpdatesProvider tailnet.WorkspaceUpdatesProvider } // New constructs a codersdk client connected to an in-memory API instance. @@ -254,6 +256,13 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can options.NotificationsEnqueuer = new(testutil.FakeNotificationsEnqueuer) } + if options.WorkspaceUpdatesProvider == nil { + var err error + options.WorkspaceUpdatesProvider, err = coderd.NewUpdatesProvider(options.Logger.Named("workspace_updates"), options.Database, options.Pubsub) + require.NoError(t, err) + t.Cleanup(options.WorkspaceUpdatesProvider.Stop) + } + accessControlStore := &atomic.Pointer[dbauthz.AccessControlStore]{} var acs dbauthz.AccessControlStore = dbauthz.AGPLTemplateAccessControlStore{} accessControlStore.Store(&acs) @@ -531,6 +540,7 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can HealthcheckTimeout: options.HealthcheckTimeout, HealthcheckRefresh: options.HealthcheckRefresh, StatsBatcher: options.StatsBatcher, + WorkspaceUpdatesProvider: options.WorkspaceUpdatesProvider, WorkspaceAppsStatsCollectorOptions: options.WorkspaceAppsStatsCollectorOptions, AllowWorkspaceRenames: options.AllowWorkspaceRenames, NewTicker: options.NewTicker, diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 14e986123edb7..0cfa020e3e662 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -844,31 +844,10 @@ func (api *API) workspaceAgentClientCoordinate(rw http.ResponseWriter, r *http.R return } - // Accept a resume_token query parameter to use the same peer ID. - var ( - peerID = uuid.New() - resumeToken = r.URL.Query().Get("resume_token") - ) - if resumeToken != "" { - var err error - peerID, err = api.Options.CoordinatorResumeTokenProvider.VerifyResumeToken(ctx, resumeToken) - // If the token is missing the key ID, it's probably an old token in which - // case we just want to generate a new peer ID. - if xerrors.Is(err, jwtutils.ErrMissingKeyID) { - peerID = uuid.New() - } else if err != nil { - httpapi.Write(ctx, rw, http.StatusUnauthorized, codersdk.Response{ - Message: workspacesdk.CoordinateAPIInvalidResumeToken, - Detail: err.Error(), - Validations: []codersdk.ValidationError{ - {Field: "resume_token", Detail: workspacesdk.CoordinateAPIInvalidResumeToken}, - }, - }) - return - } else { - api.Logger.Debug(ctx, "accepted coordinate resume token for peer", - slog.F("peer_id", peerID.String())) - } + peerID, err := api.handleResumeToken(ctx, rw, r) + if err != nil { + // handleResumeToken has already written the response. + return } api.WebsocketWaitMutex.Lock() @@ -898,6 +877,33 @@ func (api *API) workspaceAgentClientCoordinate(rw http.ResponseWriter, r *http.R } } +// handleResumeToken accepts a resume_token query parameter to use the same peer ID +func (api *API) handleResumeToken(ctx context.Context, rw http.ResponseWriter, r *http.Request) (peerID uuid.UUID, err error) { + peerID = uuid.New() + resumeToken := r.URL.Query().Get("resume_token") + if resumeToken != "" { + peerID, err = api.Options.CoordinatorResumeTokenProvider.VerifyResumeToken(ctx, resumeToken) + // If the token is missing the key ID, it's probably an old token in which + // case we just want to generate a new peer ID. + if xerrors.Is(err, jwtutils.ErrMissingKeyID) { + peerID = uuid.New() + } else if err != nil { + httpapi.Write(ctx, rw, http.StatusUnauthorized, codersdk.Response{ + Message: workspacesdk.CoordinateAPIInvalidResumeToken, + Detail: err.Error(), + Validations: []codersdk.ValidationError{ + {Field: "resume_token", Detail: workspacesdk.CoordinateAPIInvalidResumeToken}, + }, + }) + return + } else { + api.Logger.Debug(ctx, "accepted coordinate resume token for peer", + slog.F("peer_id", peerID.String())) + } + } + return peerID, err +} + // @Summary Post workspace agent log source // @ID post-workspace-agent-log-source // @Security CoderSessionToken @@ -1469,6 +1475,72 @@ func (api *API) workspaceAgentsExternalAuthListen(ctx context.Context, rw http.R } } +// @Summary Coordinate multiple workspace agents +// @ID coordinate-multiple-workspace-agents +// @Security CoderSessionToken +// @Tags Agents +// @Success 101 +// @Router /users/me/tailnet [get] +func (api *API) tailnet(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + apiKey, ok := httpmw.APIKeyOptional(r) + if !ok { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Cannot use \"me\" without a valid session.", + }) + return + } + + version := "2.0" + qv := r.URL.Query().Get("version") + if qv != "" { + version = qv + } + if err := proto.CurrentVersion.Validate(version); err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Unknown or unsupported API version", + Validations: []codersdk.ValidationError{ + {Field: "version", Detail: err.Error()}, + }, + }) + return + } + + peerID, err := api.handleResumeToken(ctx, rw, r) + if err != nil { + // handleResumeToken has already written the response. + return + } + + api.WebsocketWaitMutex.Lock() + api.WebsocketWaitGroup.Add(1) + api.WebsocketWaitMutex.Unlock() + defer api.WebsocketWaitGroup.Done() + + conn, err := websocket.Accept(rw, r, nil) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Failed to accept websocket.", + Detail: err.Error(), + }) + return + } + ctx, wsNetConn := codersdk.WebsocketNetConn(ctx, conn, websocket.MessageBinary) + defer wsNetConn.Close() + defer conn.Close(websocket.StatusNormalClosure, "") + + go httpapi.Heartbeat(ctx, conn) + err = api.TailnetClientService.ServeUserClient(ctx, version, wsNetConn, tailnet.ServeUserClientOptions{ + PeerID: peerID, + UserID: apiKey.UserID, + UpdatesProvider: api.WorkspaceUpdatesProvider, + }) + if err != nil && !xerrors.Is(err, io.EOF) && !xerrors.Is(err, context.Canceled) { + _ = conn.Close(websocket.StatusInternalError, err.Error()) + return + } +} + // createExternalAuthResponse creates an ExternalAuthResponse based on the // provider type. This is to support legacy `/workspaceagents/me/gitauth` // which uses `Username` and `Password`. diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index ba677975471d6..aaaf1499bef95 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -1,6 +1,7 @@ package coderd_test import ( + "bytes" "context" "encoding/json" "fmt" @@ -38,6 +39,7 @@ import ( "github.com/coder/coder/v2/coderd/database/pubsub" "github.com/coder/coder/v2/coderd/externalauth" "github.com/coder/coder/v2/coderd/jwtutils" + "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/agentsdk" "github.com/coder/coder/v2/codersdk/workspacesdk" @@ -1930,6 +1932,127 @@ func TestWorkspaceAgentExternalAuthListen(t *testing.T) { }) } +func TestOwnedWorkspacesCoordinate(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug) + firstClient, closer, _ := coderdtest.NewWithAPI(t, &coderdtest.Options{ + Coordinator: tailnet.NewCoordinator(logger), + IncludeProvisionerDaemon: true, + }) + defer closer.Close() + firstUser := coderdtest.CreateFirstUser(t, firstClient) + user, _ := coderdtest.CreateAnotherUser(t, firstClient, firstUser.OrganizationID, rbac.RoleTemplateAdmin()) + + // Create a workspace + token := uuid.NewString() + resources, _ := buildWorkspaceWithAgent(t, user, firstUser.OrganizationID, token) + + u, err := user.URL.Parse("/api/v2/users/me/tailnet") + require.NoError(t, err) + q := u.Query() + q.Set("version", "2.0") + u.RawQuery = q.Encode() + + //nolint:bodyclose // websocket package closes this for you + wsConn, resp, err := websocket.Dial(ctx, u.String(), &websocket.DialOptions{ + HTTPHeader: http.Header{ + "Coder-Session-Token": []string{user.SessionToken()}, + }, + }) + if err != nil { + if resp.StatusCode != http.StatusSwitchingProtocols { + err = codersdk.ReadBodyAsError(resp) + } + require.NoError(t, err) + } + defer wsConn.Close(websocket.StatusNormalClosure, "done") + + rpcClient, err := tailnet.NewDRPCClient( + websocket.NetConn(ctx, wsConn, websocket.MessageBinary), + logger, + ) + require.NoError(t, err) + + stream, err := rpcClient.WorkspaceUpdates(ctx, &tailnetproto.WorkspaceUpdatesRequest{}) + require.NoError(t, err) + + // Existing workspace + update, err := stream.Recv() + require.NoError(t, err) + require.Len(t, update.UpsertedWorkspaces, 1) + require.Equal(t, update.UpsertedWorkspaces[0].Status, tailnetproto.Workspace_RUNNING) + wsID := update.UpsertedWorkspaces[0].Id + + // Existing agent + require.Len(t, update.UpsertedAgents, 1) + require.Equal(t, update.UpsertedAgents[0].WorkspaceId, wsID) + require.EqualValues(t, update.UpsertedAgents[0].Id, resources[0].Agents[0].ID) + + require.Len(t, update.DeletedWorkspaces, 0) + require.Len(t, update.DeletedAgents, 0) + + // Build a second workspace + secondToken := uuid.NewString() + secondResources, secondWorkspace := buildWorkspaceWithAgent(t, user, firstUser.OrganizationID, secondToken) + + // Workspace starting + update, err = stream.Recv() + require.NoError(t, err) + require.Len(t, update.UpsertedWorkspaces, 1) + require.Equal(t, update.UpsertedWorkspaces[0].Status, tailnetproto.Workspace_STARTING) + + require.Len(t, update.DeletedWorkspaces, 0) + require.Len(t, update.DeletedAgents, 0) + require.Len(t, update.UpsertedAgents, 0) + + // Workspace running, agent created + update, err = stream.Recv() + require.NoError(t, err) + require.Len(t, update.UpsertedWorkspaces, 1) + require.Equal(t, update.UpsertedWorkspaces[0].Status, tailnetproto.Workspace_RUNNING) + wsID = update.UpsertedWorkspaces[0].Id + require.Len(t, update.UpsertedAgents, 1) + require.Equal(t, update.UpsertedAgents[0].WorkspaceId, wsID) + require.EqualValues(t, update.UpsertedAgents[0].Id, secondResources[0].Agents[0].ID) + + require.Len(t, update.DeletedWorkspaces, 0) + require.Len(t, update.DeletedAgents, 0) + + _, err = user.CreateWorkspaceBuild(ctx, secondWorkspace.ID, codersdk.CreateWorkspaceBuildRequest{ + Transition: codersdk.WorkspaceTransitionDelete, + }) + require.NoError(t, err) + + // Wait for the workspace to be deleted + deletedAgents := make([]*tailnetproto.Agent, 0) + workspaceUpdates := make([]*tailnetproto.Workspace, 0) + require.Eventually(t, func() bool { + update, err = stream.Recv() + if err != nil { + return false + } + deletedAgents = append(deletedAgents, update.DeletedAgents...) + workspaceUpdates = append(workspaceUpdates, update.UpsertedWorkspaces...) + return len(update.DeletedWorkspaces) == 1 && + bytes.Equal(update.DeletedWorkspaces[0].Id, wsID) + }, testutil.WaitMedium, testutil.IntervalSlow) + + // We should have seen an update for the agent being deleted + require.Len(t, deletedAgents, 1) + require.EqualValues(t, deletedAgents[0].Id, secondResources[0].Agents[0].ID) + + // But we may also see a 'pending' state transition before 'deleting' + deletingFound := false + for _, ws := range workspaceUpdates { + if bytes.Equal(ws.Id, wsID) && ws.Status == tailnetproto.Workspace_DELETING { + deletingFound = true + } + } + require.True(t, deletingFound) +} + func requireGetManifest(ctx context.Context, t testing.TB, aAPI agentproto.DRPCAgentClient) agentsdk.Manifest { mp, err := aAPI.GetManifest(ctx, &agentproto.GetManifestRequest{}) require.NoError(t, err) @@ -1949,3 +2072,18 @@ func postStartup(ctx context.Context, t testing.TB, client agent.Client, startup _, err = aAPI.UpdateStartup(ctx, &agentproto.UpdateStartupRequest{Startup: startup}) return err } + +func buildWorkspaceWithAgent(t *testing.T, client *codersdk.Client, orgID uuid.UUID, agentToken string) ([]codersdk.WorkspaceResource, codersdk.Workspace) { + version := coderdtest.CreateTemplateVersion(t, client, orgID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: echo.PlanComplete, + ProvisionApply: echo.ProvisionApplyWithAgent(agentToken), + }) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, orgID, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + _ = agenttest.New(t, client.URL, agentToken) + resources := coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).Wait() + return resources, workspace +} diff --git a/coderd/workspacebuilds.go b/coderd/workspacebuilds.go index da785ac3a5a8a..0974d85b54d6c 100644 --- a/coderd/workspacebuilds.go +++ b/coderd/workspacebuilds.go @@ -916,7 +916,7 @@ func (api *API) convertWorkspaceBuild( MaxDeadline: codersdk.NewNullTime(build.MaxDeadline, !build.MaxDeadline.IsZero()), Reason: codersdk.BuildReason(build.Reason), Resources: apiResources, - Status: convertWorkspaceStatus(apiJob.Status, transition), + Status: codersdk.ConvertWorkspaceStatus(apiJob.Status, transition), DailyCost: build.DailyCost, }, nil } @@ -946,40 +946,6 @@ func convertWorkspaceResource(resource database.WorkspaceResource, agents []code } } -func convertWorkspaceStatus(jobStatus codersdk.ProvisionerJobStatus, transition codersdk.WorkspaceTransition) codersdk.WorkspaceStatus { - switch jobStatus { - case codersdk.ProvisionerJobPending: - return codersdk.WorkspaceStatusPending - case codersdk.ProvisionerJobRunning: - switch transition { - case codersdk.WorkspaceTransitionStart: - return codersdk.WorkspaceStatusStarting - case codersdk.WorkspaceTransitionStop: - return codersdk.WorkspaceStatusStopping - case codersdk.WorkspaceTransitionDelete: - return codersdk.WorkspaceStatusDeleting - } - case codersdk.ProvisionerJobSucceeded: - switch transition { - case codersdk.WorkspaceTransitionStart: - return codersdk.WorkspaceStatusRunning - case codersdk.WorkspaceTransitionStop: - return codersdk.WorkspaceStatusStopped - case codersdk.WorkspaceTransitionDelete: - return codersdk.WorkspaceStatusDeleted - } - case codersdk.ProvisionerJobCanceling: - return codersdk.WorkspaceStatusCanceling - case codersdk.ProvisionerJobCanceled: - return codersdk.WorkspaceStatusCanceled - case codersdk.ProvisionerJobFailed: - return codersdk.WorkspaceStatusFailed - } - - // return error status since we should never get here - return codersdk.WorkspaceStatusFailed -} - func (api *API) buildTimings(ctx context.Context, build database.WorkspaceBuild) (codersdk.WorkspaceBuildTimings, error) { provisionerTimings, err := api.Database.GetProvisionerJobTimingsByJobID(ctx, build.JobID) if err != nil && !errors.Is(err, sql.ErrNoRows) { diff --git a/coderd/workspaceupdates.go b/coderd/workspaceupdates.go new file mode 100644 index 0000000000000..c5ee4055955b3 --- /dev/null +++ b/coderd/workspaceupdates.go @@ -0,0 +1,299 @@ +package coderd + +import ( + "context" + "fmt" + "sync" + + "github.com/google/uuid" + "golang.org/x/xerrors" + + "cdr.dev/slog" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/pubsub" + "github.com/coder/coder/v2/coderd/util/slice" + "github.com/coder/coder/v2/coderd/wspubsub" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/tailnet" + "github.com/coder/coder/v2/tailnet/proto" +) + +type workspacesByID = map[uuid.UUID]ownedWorkspace + +type ownedWorkspace struct { + WorkspaceName string + Status proto.Workspace_Status + Agents []database.AgentIDNamePair +} + +// Equal does not compare agents +func (w ownedWorkspace) Equal(other ownedWorkspace) bool { + return w.WorkspaceName == other.WorkspaceName && + w.Status == other.Status +} + +type sub struct { + mu sync.RWMutex + userID uuid.UUID + tx chan<- *proto.WorkspaceUpdate + prev workspacesByID + + db UpdateQuerier + ps pubsub.Pubsub + logger slog.Logger + + cancelFn func() +} + +func (s *sub) ownsAgent(agentID uuid.UUID) bool { + s.mu.RLock() + defer s.mu.RUnlock() + + for _, workspace := range s.prev { + for _, a := range workspace.Agents { + if a.ID == agentID { + return true + } + } + } + return false +} + +func (s *sub) handleEvent(ctx context.Context, event wspubsub.WorkspaceEvent) { + s.mu.Lock() + defer s.mu.Unlock() + + switch event.Kind { + case wspubsub.WorkspaceEventKindStateChange: + case wspubsub.WorkspaceEventKindAgentConnectionUpdate: + case wspubsub.WorkspaceEventKindAgentTimeout: + case wspubsub.WorkspaceEventKindAgentLifecycleUpdate: + default: + return + } + + row, err := s.db.GetWorkspacesAndAgentsByOwnerID(context.Background(), s.userID) + if err != nil { + s.logger.Warn(ctx, "failed to get workspaces and agents by owner ID", slog.Error(err)) + } + latest := convertRows(row) + + out, updated := produceUpdate(s.prev, latest) + if !updated { + return + } + + s.prev = latest + s.tx <- out +} + +func (s *sub) start() (err error) { + s.mu.Lock() + defer s.mu.Unlock() + + rows, err := s.db.GetWorkspacesAndAgentsByOwnerID(context.Background(), s.userID) + if err != nil { + return xerrors.Errorf("get workspaces and agents by owner ID: %w", err) + } + + latest := convertRows(rows) + initUpdate, _ := produceUpdate(workspacesByID{}, latest) + s.tx <- initUpdate + s.prev = latest + + cancel, err := s.ps.Subscribe(wspubsub.WorkspaceEventChannel(s.userID), wspubsub.HandleWorkspaceEvent(s.logger, s.handleEvent)) + if err != nil { + return xerrors.Errorf("subscribe to workspace event channel: %w", err) + } + + s.cancelFn = cancel + return nil +} + +func (s *sub) stop() { + s.mu.Lock() + defer s.mu.Unlock() + + if s.cancelFn != nil { + s.cancelFn() + } + + close(s.tx) +} + +type UpdateQuerier interface { + GetWorkspacesAndAgentsByOwnerID(ctx context.Context, ownerID uuid.UUID) ([]database.GetWorkspacesAndAgentsByOwnerIDRow, error) +} + +type updatesProvider struct { + mu sync.RWMutex + // Peer ID -> subscription + subs map[uuid.UUID]*sub + + db UpdateQuerier + ps pubsub.Pubsub + logger slog.Logger +} + +func (u *updatesProvider) OwnsAgent(userID uuid.UUID, agentID uuid.UUID) bool { + u.mu.RLock() + defer u.mu.RUnlock() + + for _, sub := range u.subs { + if sub.userID == userID && sub.ownsAgent(agentID) { + return true + } + } + return false +} + +var _ tailnet.WorkspaceUpdatesProvider = (*updatesProvider)(nil) + +func NewUpdatesProvider(logger slog.Logger, db UpdateQuerier, ps pubsub.Pubsub) (tailnet.WorkspaceUpdatesProvider, error) { + out := &updatesProvider{ + db: db, + ps: ps, + logger: logger, + subs: map[uuid.UUID]*sub{}, + } + return out, nil +} + +func (u *updatesProvider) Stop() { + for _, sub := range u.subs { + sub.stop() + } +} + +func (u *updatesProvider) Subscribe(peerID uuid.UUID, userID uuid.UUID) (<-chan *proto.WorkspaceUpdate, error) { + u.mu.Lock() + defer u.mu.Unlock() + + tx := make(chan *proto.WorkspaceUpdate, 1) + sub := &sub{ + userID: userID, + tx: tx, + db: u.db, + ps: u.ps, + logger: u.logger.Named(fmt.Sprintf("workspace_updates_subscriber_%s", peerID)), + prev: workspacesByID{}, + } + err := sub.start() + if err != nil { + sub.stop() + return nil, err + } + + u.subs[peerID] = sub + return tx, nil +} + +func (u *updatesProvider) Unsubscribe(peerID uuid.UUID) { + u.mu.Lock() + defer u.mu.Unlock() + + sub, exists := u.subs[peerID] + if !exists { + return + } + sub.stop() + delete(u.subs, peerID) +} + +func produceUpdate(old, new workspacesByID) (out *proto.WorkspaceUpdate, updated bool) { + out = &proto.WorkspaceUpdate{ + UpsertedWorkspaces: []*proto.Workspace{}, + UpsertedAgents: []*proto.Agent{}, + DeletedWorkspaces: []*proto.Workspace{}, + DeletedAgents: []*proto.Agent{}, + } + + for wsID, newWorkspace := range new { + oldWorkspace, exists := old[wsID] + // Upsert both workspace and agents if the workspace is new + if !exists { + out.UpsertedWorkspaces = append(out.UpsertedWorkspaces, &proto.Workspace{ + Id: tailnet.UUIDToByteSlice(wsID), + Name: newWorkspace.WorkspaceName, + Status: newWorkspace.Status, + }) + for _, agent := range newWorkspace.Agents { + out.UpsertedAgents = append(out.UpsertedAgents, &proto.Agent{ + Id: tailnet.UUIDToByteSlice(agent.ID), + Name: agent.Name, + WorkspaceId: tailnet.UUIDToByteSlice(wsID), + }) + } + updated = true + continue + } + // Upsert workspace if the workspace is updated + if !newWorkspace.Equal(oldWorkspace) { + out.UpsertedWorkspaces = append(out.UpsertedWorkspaces, &proto.Workspace{ + Id: tailnet.UUIDToByteSlice(wsID), + Name: newWorkspace.WorkspaceName, + Status: newWorkspace.Status, + }) + updated = true + } + + add, remove := slice.SymmetricDifference(oldWorkspace.Agents, newWorkspace.Agents) + for _, agent := range add { + out.UpsertedAgents = append(out.UpsertedAgents, &proto.Agent{ + Id: tailnet.UUIDToByteSlice(agent.ID), + Name: agent.Name, + WorkspaceId: tailnet.UUIDToByteSlice(wsID), + }) + updated = true + } + for _, agent := range remove { + out.DeletedAgents = append(out.DeletedAgents, &proto.Agent{ + Id: tailnet.UUIDToByteSlice(agent.ID), + Name: agent.Name, + WorkspaceId: tailnet.UUIDToByteSlice(wsID), + }) + updated = true + } + } + + // Delete workspace and agents if the workspace is deleted + for wsID, oldWorkspace := range old { + if _, exists := new[wsID]; !exists { + out.DeletedWorkspaces = append(out.DeletedWorkspaces, &proto.Workspace{ + Id: tailnet.UUIDToByteSlice(wsID), + Name: oldWorkspace.WorkspaceName, + Status: oldWorkspace.Status, + }) + for _, agent := range oldWorkspace.Agents { + out.DeletedAgents = append(out.DeletedAgents, &proto.Agent{ + Id: tailnet.UUIDToByteSlice(agent.ID), + Name: agent.Name, + WorkspaceId: tailnet.UUIDToByteSlice(wsID), + }) + } + updated = true + } + } + + return out, updated +} + +func convertRows(rows []database.GetWorkspacesAndAgentsByOwnerIDRow) workspacesByID { + out := workspacesByID{} + for _, row := range rows { + agents := []database.AgentIDNamePair{} + for _, agent := range row.Agents { + agents = append(agents, database.AgentIDNamePair{ + ID: agent.ID, + Name: agent.Name, + }) + } + out[row.ID] = ownedWorkspace{ + WorkspaceName: row.Name, + Status: tailnet.WorkspaceStatusToProto(codersdk.ConvertWorkspaceStatus(codersdk.ProvisionerJobStatus(row.JobStatus), codersdk.WorkspaceTransition(row.Transition))), + Agents: agents, + } + } + return out +} diff --git a/coderd/workspaceupdates_test.go b/coderd/workspaceupdates_test.go new file mode 100644 index 0000000000000..fa267596a6b44 --- /dev/null +++ b/coderd/workspaceupdates_test.go @@ -0,0 +1,313 @@ +package coderd_test + +import ( + "context" + "encoding/json" + "slices" + "strings" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "cdr.dev/slog/sloggers/slogtest" + "github.com/coder/coder/v2/coderd" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/pubsub" + "github.com/coder/coder/v2/coderd/wspubsub" + "github.com/coder/coder/v2/tailnet" + "github.com/coder/coder/v2/tailnet/proto" + "github.com/coder/coder/v2/testutil" +) + +func TestWorkspaceUpdates(t *testing.T) { + t.Parallel() + ctx := context.Background() + + peerID := uuid.New() + + ws1ID := uuid.New() + ws1IDSlice := tailnet.UUIDToByteSlice(ws1ID) + agent1ID := uuid.New() + agent1IDSlice := tailnet.UUIDToByteSlice(agent1ID) + ws2ID := uuid.New() + ws2IDSlice := tailnet.UUIDToByteSlice(ws2ID) + ws3ID := uuid.New() + ws3IDSlice := tailnet.UUIDToByteSlice(ws3ID) + ownerID := uuid.New() + agent2ID := uuid.New() + agent2IDSlice := tailnet.UUIDToByteSlice(agent2ID) + ws4ID := uuid.New() + ws4IDSlice := tailnet.UUIDToByteSlice(ws4ID) + + t.Run("Basic", func(t *testing.T) { + t.Parallel() + + db := &mockWorkspaceStore{ + orderedRows: []database.GetWorkspacesAndAgentsByOwnerIDRow{ + // Gains a new agent + { + ID: ws1ID, + Name: "ws1", + JobStatus: database.ProvisionerJobStatusRunning, + Transition: database.WorkspaceTransitionStart, + Agents: []database.AgentIDNamePair{ + { + ID: agent1ID, + Name: "agent1", + }, + }, + }, + // Changes status + { + ID: ws2ID, + Name: "ws2", + JobStatus: database.ProvisionerJobStatusRunning, + Transition: database.WorkspaceTransitionStart, + }, + // Is deleted + { + ID: ws3ID, + Name: "ws3", + JobStatus: database.ProvisionerJobStatusSucceeded, + Transition: database.WorkspaceTransitionStop, + }, + }, + } + + ps := &mockPubsub{ + cbs: map[string]pubsub.Listener{}, + } + + updateProvider, err := coderd.NewUpdatesProvider(slogtest.Make(t, nil), db, ps) + defer updateProvider.Stop() + require.NoError(t, err) + + ch, err := updateProvider.Subscribe(peerID, ownerID) + require.NoError(t, err) + + update, ok := <-ch + require.True(t, ok) + slices.SortFunc(update.UpsertedWorkspaces, func(a, b *proto.Workspace) int { + return strings.Compare(a.Name, b.Name) + }) + require.Equal(t, &proto.WorkspaceUpdate{ + UpsertedWorkspaces: []*proto.Workspace{ + { + Id: ws1IDSlice, + Name: "ws1", + Status: proto.Workspace_STARTING, + }, + { + Id: ws2IDSlice, + Name: "ws2", + Status: proto.Workspace_STARTING, + }, + { + Id: ws3IDSlice, + Name: "ws3", + Status: proto.Workspace_STOPPED, + }, + }, + UpsertedAgents: []*proto.Agent{ + { + Id: agent1IDSlice, + Name: "agent1", + WorkspaceId: ws1IDSlice, + }, + }, + DeletedWorkspaces: []*proto.Workspace{}, + DeletedAgents: []*proto.Agent{}, + }, update) + + // Update the database + db.orderedRows = []database.GetWorkspacesAndAgentsByOwnerIDRow{ + { + ID: ws1ID, + Name: "ws1", + JobStatus: database.ProvisionerJobStatusRunning, + Transition: database.WorkspaceTransitionStart, + Agents: []database.AgentIDNamePair{ + { + ID: agent1ID, + Name: "agent1", + }, + { + ID: agent2ID, + Name: "agent2", + }, + }, + }, + { + ID: ws2ID, + Name: "ws2", + JobStatus: database.ProvisionerJobStatusRunning, + Transition: database.WorkspaceTransitionStop, + }, + { + ID: ws4ID, + Name: "ws4", + JobStatus: database.ProvisionerJobStatusRunning, + Transition: database.WorkspaceTransitionStart, + }, + } + publishWorkspaceEvent(t, ps, ownerID, &wspubsub.WorkspaceEvent{ + Kind: wspubsub.WorkspaceEventKindStateChange, + WorkspaceID: ws1ID, + }) + + update, ok = <-ch + require.True(t, ok) + slices.SortFunc(update.UpsertedWorkspaces, func(a, b *proto.Workspace) int { + return strings.Compare(a.Name, b.Name) + }) + require.Equal(t, &proto.WorkspaceUpdate{ + UpsertedWorkspaces: []*proto.Workspace{ + { + // Changed status + Id: ws2IDSlice, + Name: "ws2", + Status: proto.Workspace_STOPPING, + }, + { + // New workspace + Id: ws4IDSlice, + Name: "ws4", + Status: proto.Workspace_STARTING, + }, + }, + UpsertedAgents: []*proto.Agent{ + { + Id: agent2IDSlice, + Name: "agent2", + WorkspaceId: ws1IDSlice, + }, + }, + DeletedWorkspaces: []*proto.Workspace{ + { + Id: ws3IDSlice, + Name: "ws3", + Status: proto.Workspace_STOPPED, + }, + }, + DeletedAgents: []*proto.Agent{}, + }, update) + }) + + t.Run("Resubscribe", func(t *testing.T) { + t.Parallel() + + db := &mockWorkspaceStore{ + orderedRows: []database.GetWorkspacesAndAgentsByOwnerIDRow{ + { + ID: ws1ID, + Name: "ws1", + JobStatus: database.ProvisionerJobStatusRunning, + Transition: database.WorkspaceTransitionStart, + Agents: []database.AgentIDNamePair{ + { + ID: agent1ID, + Name: "agent1", + }, + }, + }, + }, + } + + ps := &mockPubsub{ + cbs: map[string]pubsub.Listener{}, + } + + updateProvider, err := coderd.NewUpdatesProvider(slogtest.Make(t, nil), db, ps) + defer updateProvider.Stop() + require.NoError(t, err) + + ch, err := updateProvider.Subscribe(peerID, ownerID) + require.NoError(t, err) + + expected := &proto.WorkspaceUpdate{ + UpsertedWorkspaces: []*proto.Workspace{ + { + Id: ws1IDSlice, + Name: "ws1", + Status: proto.Workspace_STARTING, + }, + }, + UpsertedAgents: []*proto.Agent{ + { + Id: agent1IDSlice, + Name: "agent1", + WorkspaceId: ws1IDSlice, + }, + }, + DeletedWorkspaces: []*proto.Workspace{}, + DeletedAgents: []*proto.Agent{}, + } + + update := testutil.RequireRecvCtx(ctx, t, ch) + slices.SortFunc(update.UpsertedWorkspaces, func(a, b *proto.Workspace) int { + return strings.Compare(a.Name, b.Name) + }) + require.Equal(t, expected, update) + + updateProvider.Unsubscribe(ownerID) + require.NoError(t, err) + ch, err = updateProvider.Subscribe(peerID, ownerID) + require.NoError(t, err) + + update = testutil.RequireRecvCtx(ctx, t, ch) + slices.SortFunc(update.UpsertedWorkspaces, func(a, b *proto.Workspace) int { + return strings.Compare(a.Name, b.Name) + }) + require.Equal(t, expected, update) + }) +} + +func publishWorkspaceEvent(t *testing.T, ps pubsub.Pubsub, ownerID uuid.UUID, event *wspubsub.WorkspaceEvent) { + msg, err := json.Marshal(event) + require.NoError(t, err) + ps.Publish(wspubsub.WorkspaceEventChannel(ownerID), msg) +} + +type mockWorkspaceStore struct { + orderedRows []database.GetWorkspacesAndAgentsByOwnerIDRow +} + +// GetWorkspacesAndAgents implements tailnet.UpdateQuerier. +func (m *mockWorkspaceStore) GetWorkspacesAndAgentsByOwnerID(context.Context, uuid.UUID) ([]database.GetWorkspacesAndAgentsByOwnerIDRow, error) { + return m.orderedRows, nil +} + +var _ coderd.UpdateQuerier = (*mockWorkspaceStore)(nil) + +type mockPubsub struct { + cbs map[string]pubsub.Listener +} + +// Close implements pubsub.Pubsub. +func (*mockPubsub) Close() error { + panic("unimplemented") +} + +// Publish implements pubsub.Pubsub. +func (m *mockPubsub) Publish(event string, message []byte) error { + cb, ok := m.cbs[event] + if !ok { + return nil + } + cb(context.Background(), message) + return nil +} + +// Subscribe implements pubsub.Pubsub. +func (m *mockPubsub) Subscribe(event string, listener pubsub.Listener) (cancel func(), err error) { + m.cbs[event] = listener + return func() {}, nil +} + +// SubscribeWithErr implements pubsub.Pubsub. +func (*mockPubsub) SubscribeWithErr(string, pubsub.ListenerWithErr) (func(), error) { + panic("unimplemented") +} + +var _ pubsub.Pubsub = (*mockPubsub)(nil) diff --git a/codersdk/provisionerdaemons.go b/codersdk/provisionerdaemons.go index 7ba10539b671c..7b14afbbb285a 100644 --- a/codersdk/provisionerdaemons.go +++ b/codersdk/provisionerdaemons.go @@ -402,3 +402,37 @@ func (c *Client) DeleteProvisionerKey(ctx context.Context, organizationID uuid.U } return nil } + +func ConvertWorkspaceStatus(jobStatus ProvisionerJobStatus, transition WorkspaceTransition) WorkspaceStatus { + switch jobStatus { + case ProvisionerJobPending: + return WorkspaceStatusPending + case ProvisionerJobRunning: + switch transition { + case WorkspaceTransitionStart: + return WorkspaceStatusStarting + case WorkspaceTransitionStop: + return WorkspaceStatusStopping + case WorkspaceTransitionDelete: + return WorkspaceStatusDeleting + } + case ProvisionerJobSucceeded: + switch transition { + case WorkspaceTransitionStart: + return WorkspaceStatusRunning + case WorkspaceTransitionStop: + return WorkspaceStatusStopped + case WorkspaceTransitionDelete: + return WorkspaceStatusDeleted + } + case ProvisionerJobCanceling: + return WorkspaceStatusCanceling + case ProvisionerJobCanceled: + return WorkspaceStatusCanceled + case ProvisionerJobFailed: + return WorkspaceStatusFailed + } + + // return error status since we should never get here + return WorkspaceStatusFailed +} diff --git a/codersdk/workspacesdk/connector_internal_test.go b/codersdk/workspacesdk/connector_internal_test.go index 19f1930c89bc5..009de5c6bfb4a 100644 --- a/codersdk/workspacesdk/connector_internal_test.go +++ b/codersdk/workspacesdk/connector_internal_test.go @@ -580,6 +580,11 @@ func (f *fakeDRPCClient) RefreshResumeToken(_ context.Context, _ *proto.RefreshR }, nil } +// WorkspaceUpdates implements proto.DRPCTailnetClient. +func (*fakeDRPCClient) WorkspaceUpdates(context.Context, *proto.WorkspaceUpdatesRequest) (proto.DRPCTailnet_WorkspaceUpdatesClient, error) { + panic("unimplemented") +} + type fakeDRPCConn struct{} var _ drpc.Conn = &fakeDRPCConn{} diff --git a/docs/reference/api/agents.md b/docs/reference/api/agents.md index 8e7f46bc7d366..d3e3f5775c192 100644 --- a/docs/reference/api/agents.md +++ b/docs/reference/api/agents.md @@ -20,6 +20,26 @@ curl -X GET http://coder-server:8080/api/v2/derp-map \ To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Coordinate multiple workspace agents + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/users/me/tailnet \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /users/me/tailnet` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------------------------ | ------------------- | ------ | +| 101 | [Switching Protocols](https://tools.ietf.org/html/rfc7231#section-6.2.2) | Switching Protocols | | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Authenticate agent on AWS instance ### Code samples diff --git a/tailnet/convert.go b/tailnet/convert.go index a7d224dc01bd0..3ba97e443fb38 100644 --- a/tailnet/convert.go +++ b/tailnet/convert.go @@ -9,6 +9,7 @@ import ( "tailscale.com/tailcfg" "tailscale.com/types/key" + "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/tailnet/proto" ) @@ -270,3 +271,30 @@ func DERPNodeFromProto(node *proto.DERPMap_Region_Node) *tailcfg.DERPNode { CanPort80: node.CanPort_80, } } + +func WorkspaceStatusToProto(status codersdk.WorkspaceStatus) proto.Workspace_Status { + switch status { + case codersdk.WorkspaceStatusCanceled: + return proto.Workspace_CANCELED + case codersdk.WorkspaceStatusCanceling: + return proto.Workspace_CANCELING + case codersdk.WorkspaceStatusDeleted: + return proto.Workspace_DELETED + case codersdk.WorkspaceStatusDeleting: + return proto.Workspace_DELETING + case codersdk.WorkspaceStatusFailed: + return proto.Workspace_FAILED + case codersdk.WorkspaceStatusPending: + return proto.Workspace_PENDING + case codersdk.WorkspaceStatusRunning: + return proto.Workspace_RUNNING + case codersdk.WorkspaceStatusStarting: + return proto.Workspace_STARTING + case codersdk.WorkspaceStatusStopped: + return proto.Workspace_STOPPED + case codersdk.WorkspaceStatusStopping: + return proto.Workspace_STOPPING + default: + return proto.Workspace_UNKNOWN + } +} diff --git a/tailnet/proto/tailnet.pb.go b/tailnet/proto/tailnet.pb.go index c4302954c068e..78816f6da3429 100644 --- a/tailnet/proto/tailnet.pb.go +++ b/tailnet/proto/tailnet.pb.go @@ -228,6 +228,79 @@ func (TelemetryEvent_ClientType) EnumDescriptor() ([]byte, []int) { return file_tailnet_proto_tailnet_proto_rawDescGZIP(), []int{9, 1} } +type Workspace_Status int32 + +const ( + Workspace_UNKNOWN Workspace_Status = 0 + Workspace_PENDING Workspace_Status = 1 + Workspace_STARTING Workspace_Status = 2 + Workspace_RUNNING Workspace_Status = 3 + Workspace_STOPPING Workspace_Status = 4 + Workspace_STOPPED Workspace_Status = 5 + Workspace_FAILED Workspace_Status = 6 + Workspace_CANCELING Workspace_Status = 7 + Workspace_CANCELED Workspace_Status = 8 + Workspace_DELETING Workspace_Status = 9 + Workspace_DELETED Workspace_Status = 10 +) + +// Enum value maps for Workspace_Status. +var ( + Workspace_Status_name = map[int32]string{ + 0: "UNKNOWN", + 1: "PENDING", + 2: "STARTING", + 3: "RUNNING", + 4: "STOPPING", + 5: "STOPPED", + 6: "FAILED", + 7: "CANCELING", + 8: "CANCELED", + 9: "DELETING", + 10: "DELETED", + } + Workspace_Status_value = map[string]int32{ + "UNKNOWN": 0, + "PENDING": 1, + "STARTING": 2, + "RUNNING": 3, + "STOPPING": 4, + "STOPPED": 5, + "FAILED": 6, + "CANCELING": 7, + "CANCELED": 8, + "DELETING": 9, + "DELETED": 10, + } +) + +func (x Workspace_Status) Enum() *Workspace_Status { + p := new(Workspace_Status) + *p = x + return p +} + +func (x Workspace_Status) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (Workspace_Status) Descriptor() protoreflect.EnumDescriptor { + return file_tailnet_proto_tailnet_proto_enumTypes[4].Descriptor() +} + +func (Workspace_Status) Type() protoreflect.EnumType { + return &file_tailnet_proto_tailnet_proto_enumTypes[4] +} + +func (x Workspace_Status) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use Workspace_Status.Descriptor instead. +func (Workspace_Status) EnumDescriptor() ([]byte, []int) { + return file_tailnet_proto_tailnet_proto_rawDescGZIP(), []int{14, 0} +} + type DERPMap struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -1174,6 +1247,241 @@ func (*TelemetryResponse) Descriptor() ([]byte, []int) { return file_tailnet_proto_tailnet_proto_rawDescGZIP(), []int{11} } +type WorkspaceUpdatesRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields +} + +func (x *WorkspaceUpdatesRequest) Reset() { + *x = WorkspaceUpdatesRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_tailnet_proto_tailnet_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *WorkspaceUpdatesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WorkspaceUpdatesRequest) ProtoMessage() {} + +func (x *WorkspaceUpdatesRequest) ProtoReflect() protoreflect.Message { + mi := &file_tailnet_proto_tailnet_proto_msgTypes[12] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WorkspaceUpdatesRequest.ProtoReflect.Descriptor instead. +func (*WorkspaceUpdatesRequest) Descriptor() ([]byte, []int) { + return file_tailnet_proto_tailnet_proto_rawDescGZIP(), []int{12} +} + +type WorkspaceUpdate struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + UpsertedWorkspaces []*Workspace `protobuf:"bytes,1,rep,name=upserted_workspaces,json=upsertedWorkspaces,proto3" json:"upserted_workspaces,omitempty"` + UpsertedAgents []*Agent `protobuf:"bytes,2,rep,name=upserted_agents,json=upsertedAgents,proto3" json:"upserted_agents,omitempty"` + DeletedWorkspaces []*Workspace `protobuf:"bytes,3,rep,name=deleted_workspaces,json=deletedWorkspaces,proto3" json:"deleted_workspaces,omitempty"` + DeletedAgents []*Agent `protobuf:"bytes,4,rep,name=deleted_agents,json=deletedAgents,proto3" json:"deleted_agents,omitempty"` +} + +func (x *WorkspaceUpdate) Reset() { + *x = WorkspaceUpdate{} + if protoimpl.UnsafeEnabled { + mi := &file_tailnet_proto_tailnet_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *WorkspaceUpdate) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WorkspaceUpdate) ProtoMessage() {} + +func (x *WorkspaceUpdate) ProtoReflect() protoreflect.Message { + mi := &file_tailnet_proto_tailnet_proto_msgTypes[13] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WorkspaceUpdate.ProtoReflect.Descriptor instead. +func (*WorkspaceUpdate) Descriptor() ([]byte, []int) { + return file_tailnet_proto_tailnet_proto_rawDescGZIP(), []int{13} +} + +func (x *WorkspaceUpdate) GetUpsertedWorkspaces() []*Workspace { + if x != nil { + return x.UpsertedWorkspaces + } + return nil +} + +func (x *WorkspaceUpdate) GetUpsertedAgents() []*Agent { + if x != nil { + return x.UpsertedAgents + } + return nil +} + +func (x *WorkspaceUpdate) GetDeletedWorkspaces() []*Workspace { + if x != nil { + return x.DeletedWorkspaces + } + return nil +} + +func (x *WorkspaceUpdate) GetDeletedAgents() []*Agent { + if x != nil { + return x.DeletedAgents + } + return nil +} + +type Workspace struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Id []byte `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` // UUID + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + Status Workspace_Status `protobuf:"varint,3,opt,name=status,proto3,enum=coder.tailnet.v2.Workspace_Status" json:"status,omitempty"` +} + +func (x *Workspace) Reset() { + *x = Workspace{} + if protoimpl.UnsafeEnabled { + mi := &file_tailnet_proto_tailnet_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Workspace) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Workspace) ProtoMessage() {} + +func (x *Workspace) ProtoReflect() protoreflect.Message { + mi := &file_tailnet_proto_tailnet_proto_msgTypes[14] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Workspace.ProtoReflect.Descriptor instead. +func (*Workspace) Descriptor() ([]byte, []int) { + return file_tailnet_proto_tailnet_proto_rawDescGZIP(), []int{14} +} + +func (x *Workspace) GetId() []byte { + if x != nil { + return x.Id + } + return nil +} + +func (x *Workspace) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *Workspace) GetStatus() Workspace_Status { + if x != nil { + return x.Status + } + return Workspace_UNKNOWN +} + +type Agent struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Id []byte `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` // UUID + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + WorkspaceId []byte `protobuf:"bytes,3,opt,name=workspace_id,json=workspaceId,proto3" json:"workspace_id,omitempty"` // UUID +} + +func (x *Agent) Reset() { + *x = Agent{} + if protoimpl.UnsafeEnabled { + mi := &file_tailnet_proto_tailnet_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Agent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Agent) ProtoMessage() {} + +func (x *Agent) ProtoReflect() protoreflect.Message { + mi := &file_tailnet_proto_tailnet_proto_msgTypes[15] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Agent.ProtoReflect.Descriptor instead. +func (*Agent) Descriptor() ([]byte, []int) { + return file_tailnet_proto_tailnet_proto_rawDescGZIP(), []int{15} +} + +func (x *Agent) GetId() []byte { + if x != nil { + return x.Id + } + return nil +} + +func (x *Agent) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *Agent) GetWorkspaceId() []byte { + if x != nil { + return x.WorkspaceId + } + return nil +} + type DERPMap_HomeParams struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -1185,7 +1493,7 @@ type DERPMap_HomeParams struct { func (x *DERPMap_HomeParams) Reset() { *x = DERPMap_HomeParams{} if protoimpl.UnsafeEnabled { - mi := &file_tailnet_proto_tailnet_proto_msgTypes[12] + mi := &file_tailnet_proto_tailnet_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1198,7 +1506,7 @@ func (x *DERPMap_HomeParams) String() string { func (*DERPMap_HomeParams) ProtoMessage() {} func (x *DERPMap_HomeParams) ProtoReflect() protoreflect.Message { - mi := &file_tailnet_proto_tailnet_proto_msgTypes[12] + mi := &file_tailnet_proto_tailnet_proto_msgTypes[16] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1237,7 +1545,7 @@ type DERPMap_Region struct { func (x *DERPMap_Region) Reset() { *x = DERPMap_Region{} if protoimpl.UnsafeEnabled { - mi := &file_tailnet_proto_tailnet_proto_msgTypes[13] + mi := &file_tailnet_proto_tailnet_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1250,7 +1558,7 @@ func (x *DERPMap_Region) String() string { func (*DERPMap_Region) ProtoMessage() {} func (x *DERPMap_Region) ProtoReflect() protoreflect.Message { - mi := &file_tailnet_proto_tailnet_proto_msgTypes[13] + mi := &file_tailnet_proto_tailnet_proto_msgTypes[17] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1331,7 +1639,7 @@ type DERPMap_Region_Node struct { func (x *DERPMap_Region_Node) Reset() { *x = DERPMap_Region_Node{} if protoimpl.UnsafeEnabled { - mi := &file_tailnet_proto_tailnet_proto_msgTypes[16] + mi := &file_tailnet_proto_tailnet_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1344,7 +1652,7 @@ func (x *DERPMap_Region_Node) String() string { func (*DERPMap_Region_Node) ProtoMessage() {} func (x *DERPMap_Region_Node) ProtoReflect() protoreflect.Message { - mi := &file_tailnet_proto_tailnet_proto_msgTypes[16] + mi := &file_tailnet_proto_tailnet_proto_msgTypes[20] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1462,7 +1770,7 @@ type CoordinateRequest_UpdateSelf struct { func (x *CoordinateRequest_UpdateSelf) Reset() { *x = CoordinateRequest_UpdateSelf{} if protoimpl.UnsafeEnabled { - mi := &file_tailnet_proto_tailnet_proto_msgTypes[19] + mi := &file_tailnet_proto_tailnet_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1475,7 +1783,7 @@ func (x *CoordinateRequest_UpdateSelf) String() string { func (*CoordinateRequest_UpdateSelf) ProtoMessage() {} func (x *CoordinateRequest_UpdateSelf) ProtoReflect() protoreflect.Message { - mi := &file_tailnet_proto_tailnet_proto_msgTypes[19] + mi := &file_tailnet_proto_tailnet_proto_msgTypes[23] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1507,7 +1815,7 @@ type CoordinateRequest_Disconnect struct { func (x *CoordinateRequest_Disconnect) Reset() { *x = CoordinateRequest_Disconnect{} if protoimpl.UnsafeEnabled { - mi := &file_tailnet_proto_tailnet_proto_msgTypes[20] + mi := &file_tailnet_proto_tailnet_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1520,7 +1828,7 @@ func (x *CoordinateRequest_Disconnect) String() string { func (*CoordinateRequest_Disconnect) ProtoMessage() {} func (x *CoordinateRequest_Disconnect) ProtoReflect() protoreflect.Message { - mi := &file_tailnet_proto_tailnet_proto_msgTypes[20] + mi := &file_tailnet_proto_tailnet_proto_msgTypes[24] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1547,7 +1855,7 @@ type CoordinateRequest_Tunnel struct { func (x *CoordinateRequest_Tunnel) Reset() { *x = CoordinateRequest_Tunnel{} if protoimpl.UnsafeEnabled { - mi := &file_tailnet_proto_tailnet_proto_msgTypes[21] + mi := &file_tailnet_proto_tailnet_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1560,7 +1868,7 @@ func (x *CoordinateRequest_Tunnel) String() string { func (*CoordinateRequest_Tunnel) ProtoMessage() {} func (x *CoordinateRequest_Tunnel) ProtoReflect() protoreflect.Message { - mi := &file_tailnet_proto_tailnet_proto_msgTypes[21] + mi := &file_tailnet_proto_tailnet_proto_msgTypes[25] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1598,7 +1906,7 @@ type CoordinateRequest_ReadyForHandshake struct { func (x *CoordinateRequest_ReadyForHandshake) Reset() { *x = CoordinateRequest_ReadyForHandshake{} if protoimpl.UnsafeEnabled { - mi := &file_tailnet_proto_tailnet_proto_msgTypes[22] + mi := &file_tailnet_proto_tailnet_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1611,7 +1919,7 @@ func (x *CoordinateRequest_ReadyForHandshake) String() string { func (*CoordinateRequest_ReadyForHandshake) ProtoMessage() {} func (x *CoordinateRequest_ReadyForHandshake) ProtoReflect() protoreflect.Message { - mi := &file_tailnet_proto_tailnet_proto_msgTypes[22] + mi := &file_tailnet_proto_tailnet_proto_msgTypes[26] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1648,7 +1956,7 @@ type CoordinateResponse_PeerUpdate struct { func (x *CoordinateResponse_PeerUpdate) Reset() { *x = CoordinateResponse_PeerUpdate{} if protoimpl.UnsafeEnabled { - mi := &file_tailnet_proto_tailnet_proto_msgTypes[23] + mi := &file_tailnet_proto_tailnet_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1661,7 +1969,7 @@ func (x *CoordinateResponse_PeerUpdate) String() string { func (*CoordinateResponse_PeerUpdate) ProtoMessage() {} func (x *CoordinateResponse_PeerUpdate) ProtoReflect() protoreflect.Message { - mi := &file_tailnet_proto_tailnet_proto_msgTypes[23] + mi := &file_tailnet_proto_tailnet_proto_msgTypes[27] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1717,7 +2025,7 @@ type Netcheck_NetcheckIP struct { func (x *Netcheck_NetcheckIP) Reset() { *x = Netcheck_NetcheckIP{} if protoimpl.UnsafeEnabled { - mi := &file_tailnet_proto_tailnet_proto_msgTypes[26] + mi := &file_tailnet_proto_tailnet_proto_msgTypes[30] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1730,7 +2038,7 @@ func (x *Netcheck_NetcheckIP) String() string { func (*Netcheck_NetcheckIP) ProtoMessage() {} func (x *Netcheck_NetcheckIP) ProtoReflect() protoreflect.Message { - mi := &file_tailnet_proto_tailnet_proto_msgTypes[26] + mi := &file_tailnet_proto_tailnet_proto_msgTypes[30] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1773,7 +2081,7 @@ type TelemetryEvent_P2PEndpoint struct { func (x *TelemetryEvent_P2PEndpoint) Reset() { *x = TelemetryEvent_P2PEndpoint{} if protoimpl.UnsafeEnabled { - mi := &file_tailnet_proto_tailnet_proto_msgTypes[27] + mi := &file_tailnet_proto_tailnet_proto_msgTypes[31] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1786,7 +2094,7 @@ func (x *TelemetryEvent_P2PEndpoint) String() string { func (*TelemetryEvent_P2PEndpoint) ProtoMessage() {} func (x *TelemetryEvent_P2PEndpoint) ProtoReflect() protoreflect.Message { - mi := &file_tailnet_proto_tailnet_proto_msgTypes[27] + mi := &file_tailnet_proto_tailnet_proto_msgTypes[31] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2171,35 +2479,84 @@ var file_tailnet_proto_tailnet_proto_rawDesc = []byte{ 0x2e, 0x74, 0x61, 0x69, 0x6c, 0x6e, 0x65, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x54, 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x74, 0x72, 0x79, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x52, 0x06, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x22, 0x13, 0x0a, 0x11, 0x54, 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x74, 0x72, 0x79, 0x52, - 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0x89, 0x03, 0x0a, 0x07, 0x54, 0x61, 0x69, 0x6c, - 0x6e, 0x65, 0x74, 0x12, 0x58, 0x0a, 0x0d, 0x50, 0x6f, 0x73, 0x74, 0x54, 0x65, 0x6c, 0x65, 0x6d, - 0x65, 0x74, 0x72, 0x79, 0x12, 0x22, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x74, 0x61, 0x69, - 0x6c, 0x6e, 0x65, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x54, 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x74, 0x72, - 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, - 0x2e, 0x74, 0x61, 0x69, 0x6c, 0x6e, 0x65, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x54, 0x65, 0x6c, 0x65, - 0x6d, 0x65, 0x74, 0x72, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x56, 0x0a, - 0x0e, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x44, 0x45, 0x52, 0x50, 0x4d, 0x61, 0x70, 0x73, 0x12, - 0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x74, 0x61, 0x69, 0x6c, 0x6e, 0x65, 0x74, 0x2e, - 0x76, 0x32, 0x2e, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x44, 0x45, 0x52, 0x50, 0x4d, 0x61, 0x70, - 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x19, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, - 0x2e, 0x74, 0x61, 0x69, 0x6c, 0x6e, 0x65, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x44, 0x45, 0x52, 0x50, - 0x4d, 0x61, 0x70, 0x30, 0x01, 0x12, 0x6f, 0x0a, 0x12, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, - 0x52, 0x65, 0x73, 0x75, 0x6d, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x2b, 0x2e, 0x63, 0x6f, - 0x64, 0x65, 0x72, 0x2e, 0x74, 0x61, 0x69, 0x6c, 0x6e, 0x65, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x52, - 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x52, 0x65, 0x73, 0x75, 0x6d, 0x65, 0x54, 0x6f, 0x6b, 0x65, - 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2c, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, - 0x2e, 0x74, 0x61, 0x69, 0x6c, 0x6e, 0x65, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x52, 0x65, 0x66, 0x72, - 0x65, 0x73, 0x68, 0x52, 0x65, 0x73, 0x75, 0x6d, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5b, 0x0a, 0x0a, 0x43, 0x6f, 0x6f, 0x72, 0x64, 0x69, - 0x6e, 0x61, 0x74, 0x65, 0x12, 0x23, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x74, 0x61, 0x69, - 0x6c, 0x6e, 0x65, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x43, 0x6f, 0x6f, 0x72, 0x64, 0x69, 0x6e, 0x61, - 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x63, 0x6f, 0x64, 0x65, - 0x72, 0x2e, 0x74, 0x61, 0x69, 0x6c, 0x6e, 0x65, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x43, 0x6f, 0x6f, - 0x72, 0x64, 0x69, 0x6e, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x28, - 0x01, 0x30, 0x01, 0x42, 0x29, 0x5a, 0x27, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, - 0x6d, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x76, 0x32, - 0x2f, 0x74, 0x61, 0x69, 0x6c, 0x6e, 0x65, 0x74, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, - 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x19, 0x0a, 0x17, 0x57, 0x6f, 0x72, 0x6b, 0x73, + 0x70, 0x61, 0x63, 0x65, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x22, 0xad, 0x02, 0x0a, 0x0f, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, + 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x4c, 0x0a, 0x13, 0x75, 0x70, 0x73, 0x65, 0x72, 0x74, + 0x65, 0x64, 0x5f, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x73, 0x18, 0x01, 0x20, + 0x03, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x74, 0x61, 0x69, 0x6c, + 0x6e, 0x65, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, + 0x52, 0x12, 0x75, 0x70, 0x73, 0x65, 0x72, 0x74, 0x65, 0x64, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, + 0x61, 0x63, 0x65, 0x73, 0x12, 0x40, 0x0a, 0x0f, 0x75, 0x70, 0x73, 0x65, 0x72, 0x74, 0x65, 0x64, + 0x5f, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x17, 0x2e, + 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x74, 0x61, 0x69, 0x6c, 0x6e, 0x65, 0x74, 0x2e, 0x76, 0x32, + 0x2e, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, 0x0e, 0x75, 0x70, 0x73, 0x65, 0x72, 0x74, 0x65, 0x64, + 0x41, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x12, 0x4a, 0x0a, 0x12, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, + 0x64, 0x5f, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, + 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x74, 0x61, 0x69, 0x6c, 0x6e, + 0x65, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x52, + 0x11, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, + 0x65, 0x73, 0x12, 0x3e, 0x0a, 0x0e, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x67, + 0x65, 0x6e, 0x74, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x63, 0x6f, 0x64, + 0x65, 0x72, 0x2e, 0x74, 0x61, 0x69, 0x6c, 0x6e, 0x65, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x41, 0x67, + 0x65, 0x6e, 0x74, 0x52, 0x0d, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x41, 0x67, 0x65, 0x6e, + 0x74, 0x73, 0x22, 0x8a, 0x02, 0x0a, 0x09, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, + 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x02, 0x69, 0x64, + 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, + 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x3a, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x0e, 0x32, 0x22, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x74, 0x61, 0x69, + 0x6c, 0x6e, 0x65, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, + 0x65, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, + 0x22, 0x9c, 0x01, 0x0a, 0x06, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x0b, 0x0a, 0x07, 0x55, + 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x0b, 0x0a, 0x07, 0x50, 0x45, 0x4e, 0x44, + 0x49, 0x4e, 0x47, 0x10, 0x01, 0x12, 0x0c, 0x0a, 0x08, 0x53, 0x54, 0x41, 0x52, 0x54, 0x49, 0x4e, + 0x47, 0x10, 0x02, 0x12, 0x0b, 0x0a, 0x07, 0x52, 0x55, 0x4e, 0x4e, 0x49, 0x4e, 0x47, 0x10, 0x03, + 0x12, 0x0c, 0x0a, 0x08, 0x53, 0x54, 0x4f, 0x50, 0x50, 0x49, 0x4e, 0x47, 0x10, 0x04, 0x12, 0x0b, + 0x0a, 0x07, 0x53, 0x54, 0x4f, 0x50, 0x50, 0x45, 0x44, 0x10, 0x05, 0x12, 0x0a, 0x0a, 0x06, 0x46, + 0x41, 0x49, 0x4c, 0x45, 0x44, 0x10, 0x06, 0x12, 0x0d, 0x0a, 0x09, 0x43, 0x41, 0x4e, 0x43, 0x45, + 0x4c, 0x49, 0x4e, 0x47, 0x10, 0x07, 0x12, 0x0c, 0x0a, 0x08, 0x43, 0x41, 0x4e, 0x43, 0x45, 0x4c, + 0x45, 0x44, 0x10, 0x08, 0x12, 0x0c, 0x0a, 0x08, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x49, 0x4e, 0x47, + 0x10, 0x09, 0x12, 0x0b, 0x0a, 0x07, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45, 0x44, 0x10, 0x0a, 0x22, + 0x4e, 0x0a, 0x05, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x0c, 0x52, 0x02, 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x21, 0x0a, 0x0c, + 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, + 0x28, 0x0c, 0x52, 0x0b, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x49, 0x64, 0x32, + 0xed, 0x03, 0x0a, 0x07, 0x54, 0x61, 0x69, 0x6c, 0x6e, 0x65, 0x74, 0x12, 0x58, 0x0a, 0x0d, 0x50, + 0x6f, 0x73, 0x74, 0x54, 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x74, 0x72, 0x79, 0x12, 0x22, 0x2e, 0x63, + 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x74, 0x61, 0x69, 0x6c, 0x6e, 0x65, 0x74, 0x2e, 0x76, 0x32, 0x2e, + 0x54, 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x74, 0x72, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x1a, 0x23, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x74, 0x61, 0x69, 0x6c, 0x6e, 0x65, 0x74, + 0x2e, 0x76, 0x32, 0x2e, 0x54, 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x74, 0x72, 0x79, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x56, 0x0a, 0x0e, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x44, + 0x45, 0x52, 0x50, 0x4d, 0x61, 0x70, 0x73, 0x12, 0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, + 0x74, 0x61, 0x69, 0x6c, 0x6e, 0x65, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x53, 0x74, 0x72, 0x65, 0x61, + 0x6d, 0x44, 0x45, 0x52, 0x50, 0x4d, 0x61, 0x70, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x1a, 0x19, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x74, 0x61, 0x69, 0x6c, 0x6e, 0x65, 0x74, + 0x2e, 0x76, 0x32, 0x2e, 0x44, 0x45, 0x52, 0x50, 0x4d, 0x61, 0x70, 0x30, 0x01, 0x12, 0x6f, 0x0a, + 0x12, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x52, 0x65, 0x73, 0x75, 0x6d, 0x65, 0x54, 0x6f, + 0x6b, 0x65, 0x6e, 0x12, 0x2b, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x74, 0x61, 0x69, 0x6c, + 0x6e, 0x65, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x52, 0x65, + 0x73, 0x75, 0x6d, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x1a, 0x2c, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x74, 0x61, 0x69, 0x6c, 0x6e, 0x65, 0x74, + 0x2e, 0x76, 0x32, 0x2e, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x52, 0x65, 0x73, 0x75, 0x6d, + 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5b, + 0x0a, 0x0a, 0x43, 0x6f, 0x6f, 0x72, 0x64, 0x69, 0x6e, 0x61, 0x74, 0x65, 0x12, 0x23, 0x2e, 0x63, + 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x74, 0x61, 0x69, 0x6c, 0x6e, 0x65, 0x74, 0x2e, 0x76, 0x32, 0x2e, + 0x43, 0x6f, 0x6f, 0x72, 0x64, 0x69, 0x6e, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x24, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x74, 0x61, 0x69, 0x6c, 0x6e, 0x65, + 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x43, 0x6f, 0x6f, 0x72, 0x64, 0x69, 0x6e, 0x61, 0x74, 0x65, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x28, 0x01, 0x30, 0x01, 0x12, 0x62, 0x0a, 0x10, 0x57, + 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x73, 0x12, + 0x29, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x74, 0x61, 0x69, 0x6c, 0x6e, 0x65, 0x74, 0x2e, + 0x76, 0x32, 0x2e, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x55, 0x70, 0x64, 0x61, + 0x74, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x63, 0x6f, 0x64, + 0x65, 0x72, 0x2e, 0x74, 0x61, 0x69, 0x6c, 0x6e, 0x65, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x57, 0x6f, + 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x30, 0x01, 0x42, + 0x29, 0x5a, 0x27, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x6f, + 0x64, 0x65, 0x72, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x76, 0x32, 0x2f, 0x74, 0x61, 0x69, + 0x6c, 0x6e, 0x65, 0x74, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x33, } var ( @@ -2214,107 +2571,119 @@ func file_tailnet_proto_tailnet_proto_rawDescGZIP() []byte { return file_tailnet_proto_tailnet_proto_rawDescData } -var file_tailnet_proto_tailnet_proto_enumTypes = make([]protoimpl.EnumInfo, 4) -var file_tailnet_proto_tailnet_proto_msgTypes = make([]protoimpl.MessageInfo, 28) +var file_tailnet_proto_tailnet_proto_enumTypes = make([]protoimpl.EnumInfo, 5) +var file_tailnet_proto_tailnet_proto_msgTypes = make([]protoimpl.MessageInfo, 32) var file_tailnet_proto_tailnet_proto_goTypes = []interface{}{ (CoordinateResponse_PeerUpdate_Kind)(0), // 0: coder.tailnet.v2.CoordinateResponse.PeerUpdate.Kind (IPFields_IPClass)(0), // 1: coder.tailnet.v2.IPFields.IPClass (TelemetryEvent_Status)(0), // 2: coder.tailnet.v2.TelemetryEvent.Status (TelemetryEvent_ClientType)(0), // 3: coder.tailnet.v2.TelemetryEvent.ClientType - (*DERPMap)(nil), // 4: coder.tailnet.v2.DERPMap - (*StreamDERPMapsRequest)(nil), // 5: coder.tailnet.v2.StreamDERPMapsRequest - (*Node)(nil), // 6: coder.tailnet.v2.Node - (*RefreshResumeTokenRequest)(nil), // 7: coder.tailnet.v2.RefreshResumeTokenRequest - (*RefreshResumeTokenResponse)(nil), // 8: coder.tailnet.v2.RefreshResumeTokenResponse - (*CoordinateRequest)(nil), // 9: coder.tailnet.v2.CoordinateRequest - (*CoordinateResponse)(nil), // 10: coder.tailnet.v2.CoordinateResponse - (*IPFields)(nil), // 11: coder.tailnet.v2.IPFields - (*Netcheck)(nil), // 12: coder.tailnet.v2.Netcheck - (*TelemetryEvent)(nil), // 13: coder.tailnet.v2.TelemetryEvent - (*TelemetryRequest)(nil), // 14: coder.tailnet.v2.TelemetryRequest - (*TelemetryResponse)(nil), // 15: coder.tailnet.v2.TelemetryResponse - (*DERPMap_HomeParams)(nil), // 16: coder.tailnet.v2.DERPMap.HomeParams - (*DERPMap_Region)(nil), // 17: coder.tailnet.v2.DERPMap.Region - nil, // 18: coder.tailnet.v2.DERPMap.RegionsEntry - nil, // 19: coder.tailnet.v2.DERPMap.HomeParams.RegionScoreEntry - (*DERPMap_Region_Node)(nil), // 20: coder.tailnet.v2.DERPMap.Region.Node - nil, // 21: coder.tailnet.v2.Node.DerpLatencyEntry - nil, // 22: coder.tailnet.v2.Node.DerpForcedWebsocketEntry - (*CoordinateRequest_UpdateSelf)(nil), // 23: coder.tailnet.v2.CoordinateRequest.UpdateSelf - (*CoordinateRequest_Disconnect)(nil), // 24: coder.tailnet.v2.CoordinateRequest.Disconnect - (*CoordinateRequest_Tunnel)(nil), // 25: coder.tailnet.v2.CoordinateRequest.Tunnel - (*CoordinateRequest_ReadyForHandshake)(nil), // 26: coder.tailnet.v2.CoordinateRequest.ReadyForHandshake - (*CoordinateResponse_PeerUpdate)(nil), // 27: coder.tailnet.v2.CoordinateResponse.PeerUpdate - nil, // 28: coder.tailnet.v2.Netcheck.RegionV4LatencyEntry - nil, // 29: coder.tailnet.v2.Netcheck.RegionV6LatencyEntry - (*Netcheck_NetcheckIP)(nil), // 30: coder.tailnet.v2.Netcheck.NetcheckIP - (*TelemetryEvent_P2PEndpoint)(nil), // 31: coder.tailnet.v2.TelemetryEvent.P2PEndpoint - (*timestamppb.Timestamp)(nil), // 32: google.protobuf.Timestamp - (*durationpb.Duration)(nil), // 33: google.protobuf.Duration - (*wrapperspb.BoolValue)(nil), // 34: google.protobuf.BoolValue - (*wrapperspb.FloatValue)(nil), // 35: google.protobuf.FloatValue + (Workspace_Status)(0), // 4: coder.tailnet.v2.Workspace.Status + (*DERPMap)(nil), // 5: coder.tailnet.v2.DERPMap + (*StreamDERPMapsRequest)(nil), // 6: coder.tailnet.v2.StreamDERPMapsRequest + (*Node)(nil), // 7: coder.tailnet.v2.Node + (*RefreshResumeTokenRequest)(nil), // 8: coder.tailnet.v2.RefreshResumeTokenRequest + (*RefreshResumeTokenResponse)(nil), // 9: coder.tailnet.v2.RefreshResumeTokenResponse + (*CoordinateRequest)(nil), // 10: coder.tailnet.v2.CoordinateRequest + (*CoordinateResponse)(nil), // 11: coder.tailnet.v2.CoordinateResponse + (*IPFields)(nil), // 12: coder.tailnet.v2.IPFields + (*Netcheck)(nil), // 13: coder.tailnet.v2.Netcheck + (*TelemetryEvent)(nil), // 14: coder.tailnet.v2.TelemetryEvent + (*TelemetryRequest)(nil), // 15: coder.tailnet.v2.TelemetryRequest + (*TelemetryResponse)(nil), // 16: coder.tailnet.v2.TelemetryResponse + (*WorkspaceUpdatesRequest)(nil), // 17: coder.tailnet.v2.WorkspaceUpdatesRequest + (*WorkspaceUpdate)(nil), // 18: coder.tailnet.v2.WorkspaceUpdate + (*Workspace)(nil), // 19: coder.tailnet.v2.Workspace + (*Agent)(nil), // 20: coder.tailnet.v2.Agent + (*DERPMap_HomeParams)(nil), // 21: coder.tailnet.v2.DERPMap.HomeParams + (*DERPMap_Region)(nil), // 22: coder.tailnet.v2.DERPMap.Region + nil, // 23: coder.tailnet.v2.DERPMap.RegionsEntry + nil, // 24: coder.tailnet.v2.DERPMap.HomeParams.RegionScoreEntry + (*DERPMap_Region_Node)(nil), // 25: coder.tailnet.v2.DERPMap.Region.Node + nil, // 26: coder.tailnet.v2.Node.DerpLatencyEntry + nil, // 27: coder.tailnet.v2.Node.DerpForcedWebsocketEntry + (*CoordinateRequest_UpdateSelf)(nil), // 28: coder.tailnet.v2.CoordinateRequest.UpdateSelf + (*CoordinateRequest_Disconnect)(nil), // 29: coder.tailnet.v2.CoordinateRequest.Disconnect + (*CoordinateRequest_Tunnel)(nil), // 30: coder.tailnet.v2.CoordinateRequest.Tunnel + (*CoordinateRequest_ReadyForHandshake)(nil), // 31: coder.tailnet.v2.CoordinateRequest.ReadyForHandshake + (*CoordinateResponse_PeerUpdate)(nil), // 32: coder.tailnet.v2.CoordinateResponse.PeerUpdate + nil, // 33: coder.tailnet.v2.Netcheck.RegionV4LatencyEntry + nil, // 34: coder.tailnet.v2.Netcheck.RegionV6LatencyEntry + (*Netcheck_NetcheckIP)(nil), // 35: coder.tailnet.v2.Netcheck.NetcheckIP + (*TelemetryEvent_P2PEndpoint)(nil), // 36: coder.tailnet.v2.TelemetryEvent.P2PEndpoint + (*timestamppb.Timestamp)(nil), // 37: google.protobuf.Timestamp + (*durationpb.Duration)(nil), // 38: google.protobuf.Duration + (*wrapperspb.BoolValue)(nil), // 39: google.protobuf.BoolValue + (*wrapperspb.FloatValue)(nil), // 40: google.protobuf.FloatValue } var file_tailnet_proto_tailnet_proto_depIdxs = []int32{ - 16, // 0: coder.tailnet.v2.DERPMap.home_params:type_name -> coder.tailnet.v2.DERPMap.HomeParams - 18, // 1: coder.tailnet.v2.DERPMap.regions:type_name -> coder.tailnet.v2.DERPMap.RegionsEntry - 32, // 2: coder.tailnet.v2.Node.as_of:type_name -> google.protobuf.Timestamp - 21, // 3: coder.tailnet.v2.Node.derp_latency:type_name -> coder.tailnet.v2.Node.DerpLatencyEntry - 22, // 4: coder.tailnet.v2.Node.derp_forced_websocket:type_name -> coder.tailnet.v2.Node.DerpForcedWebsocketEntry - 33, // 5: coder.tailnet.v2.RefreshResumeTokenResponse.refresh_in:type_name -> google.protobuf.Duration - 32, // 6: coder.tailnet.v2.RefreshResumeTokenResponse.expires_at:type_name -> google.protobuf.Timestamp - 23, // 7: coder.tailnet.v2.CoordinateRequest.update_self:type_name -> coder.tailnet.v2.CoordinateRequest.UpdateSelf - 24, // 8: coder.tailnet.v2.CoordinateRequest.disconnect:type_name -> coder.tailnet.v2.CoordinateRequest.Disconnect - 25, // 9: coder.tailnet.v2.CoordinateRequest.add_tunnel:type_name -> coder.tailnet.v2.CoordinateRequest.Tunnel - 25, // 10: coder.tailnet.v2.CoordinateRequest.remove_tunnel:type_name -> coder.tailnet.v2.CoordinateRequest.Tunnel - 26, // 11: coder.tailnet.v2.CoordinateRequest.ready_for_handshake:type_name -> coder.tailnet.v2.CoordinateRequest.ReadyForHandshake - 27, // 12: coder.tailnet.v2.CoordinateResponse.peer_updates:type_name -> coder.tailnet.v2.CoordinateResponse.PeerUpdate + 21, // 0: coder.tailnet.v2.DERPMap.home_params:type_name -> coder.tailnet.v2.DERPMap.HomeParams + 23, // 1: coder.tailnet.v2.DERPMap.regions:type_name -> coder.tailnet.v2.DERPMap.RegionsEntry + 37, // 2: coder.tailnet.v2.Node.as_of:type_name -> google.protobuf.Timestamp + 26, // 3: coder.tailnet.v2.Node.derp_latency:type_name -> coder.tailnet.v2.Node.DerpLatencyEntry + 27, // 4: coder.tailnet.v2.Node.derp_forced_websocket:type_name -> coder.tailnet.v2.Node.DerpForcedWebsocketEntry + 38, // 5: coder.tailnet.v2.RefreshResumeTokenResponse.refresh_in:type_name -> google.protobuf.Duration + 37, // 6: coder.tailnet.v2.RefreshResumeTokenResponse.expires_at:type_name -> google.protobuf.Timestamp + 28, // 7: coder.tailnet.v2.CoordinateRequest.update_self:type_name -> coder.tailnet.v2.CoordinateRequest.UpdateSelf + 29, // 8: coder.tailnet.v2.CoordinateRequest.disconnect:type_name -> coder.tailnet.v2.CoordinateRequest.Disconnect + 30, // 9: coder.tailnet.v2.CoordinateRequest.add_tunnel:type_name -> coder.tailnet.v2.CoordinateRequest.Tunnel + 30, // 10: coder.tailnet.v2.CoordinateRequest.remove_tunnel:type_name -> coder.tailnet.v2.CoordinateRequest.Tunnel + 31, // 11: coder.tailnet.v2.CoordinateRequest.ready_for_handshake:type_name -> coder.tailnet.v2.CoordinateRequest.ReadyForHandshake + 32, // 12: coder.tailnet.v2.CoordinateResponse.peer_updates:type_name -> coder.tailnet.v2.CoordinateResponse.PeerUpdate 1, // 13: coder.tailnet.v2.IPFields.class:type_name -> coder.tailnet.v2.IPFields.IPClass - 34, // 14: coder.tailnet.v2.Netcheck.OSHasIPv6:type_name -> google.protobuf.BoolValue - 34, // 15: coder.tailnet.v2.Netcheck.MappingVariesByDestIP:type_name -> google.protobuf.BoolValue - 34, // 16: coder.tailnet.v2.Netcheck.HairPinning:type_name -> google.protobuf.BoolValue - 34, // 17: coder.tailnet.v2.Netcheck.UPnP:type_name -> google.protobuf.BoolValue - 34, // 18: coder.tailnet.v2.Netcheck.PMP:type_name -> google.protobuf.BoolValue - 34, // 19: coder.tailnet.v2.Netcheck.PCP:type_name -> google.protobuf.BoolValue - 28, // 20: coder.tailnet.v2.Netcheck.RegionV4Latency:type_name -> coder.tailnet.v2.Netcheck.RegionV4LatencyEntry - 29, // 21: coder.tailnet.v2.Netcheck.RegionV6Latency:type_name -> coder.tailnet.v2.Netcheck.RegionV6LatencyEntry - 30, // 22: coder.tailnet.v2.Netcheck.GlobalV4:type_name -> coder.tailnet.v2.Netcheck.NetcheckIP - 30, // 23: coder.tailnet.v2.Netcheck.GlobalV6:type_name -> coder.tailnet.v2.Netcheck.NetcheckIP - 32, // 24: coder.tailnet.v2.TelemetryEvent.time:type_name -> google.protobuf.Timestamp + 39, // 14: coder.tailnet.v2.Netcheck.OSHasIPv6:type_name -> google.protobuf.BoolValue + 39, // 15: coder.tailnet.v2.Netcheck.MappingVariesByDestIP:type_name -> google.protobuf.BoolValue + 39, // 16: coder.tailnet.v2.Netcheck.HairPinning:type_name -> google.protobuf.BoolValue + 39, // 17: coder.tailnet.v2.Netcheck.UPnP:type_name -> google.protobuf.BoolValue + 39, // 18: coder.tailnet.v2.Netcheck.PMP:type_name -> google.protobuf.BoolValue + 39, // 19: coder.tailnet.v2.Netcheck.PCP:type_name -> google.protobuf.BoolValue + 33, // 20: coder.tailnet.v2.Netcheck.RegionV4Latency:type_name -> coder.tailnet.v2.Netcheck.RegionV4LatencyEntry + 34, // 21: coder.tailnet.v2.Netcheck.RegionV6Latency:type_name -> coder.tailnet.v2.Netcheck.RegionV6LatencyEntry + 35, // 22: coder.tailnet.v2.Netcheck.GlobalV4:type_name -> coder.tailnet.v2.Netcheck.NetcheckIP + 35, // 23: coder.tailnet.v2.Netcheck.GlobalV6:type_name -> coder.tailnet.v2.Netcheck.NetcheckIP + 37, // 24: coder.tailnet.v2.TelemetryEvent.time:type_name -> google.protobuf.Timestamp 2, // 25: coder.tailnet.v2.TelemetryEvent.status:type_name -> coder.tailnet.v2.TelemetryEvent.Status 3, // 26: coder.tailnet.v2.TelemetryEvent.client_type:type_name -> coder.tailnet.v2.TelemetryEvent.ClientType - 31, // 27: coder.tailnet.v2.TelemetryEvent.p2p_endpoint:type_name -> coder.tailnet.v2.TelemetryEvent.P2PEndpoint - 4, // 28: coder.tailnet.v2.TelemetryEvent.derp_map:type_name -> coder.tailnet.v2.DERPMap - 12, // 29: coder.tailnet.v2.TelemetryEvent.latest_netcheck:type_name -> coder.tailnet.v2.Netcheck - 33, // 30: coder.tailnet.v2.TelemetryEvent.connection_age:type_name -> google.protobuf.Duration - 33, // 31: coder.tailnet.v2.TelemetryEvent.connection_setup:type_name -> google.protobuf.Duration - 33, // 32: coder.tailnet.v2.TelemetryEvent.p2p_setup:type_name -> google.protobuf.Duration - 33, // 33: coder.tailnet.v2.TelemetryEvent.derp_latency:type_name -> google.protobuf.Duration - 33, // 34: coder.tailnet.v2.TelemetryEvent.p2p_latency:type_name -> google.protobuf.Duration - 35, // 35: coder.tailnet.v2.TelemetryEvent.throughput_mbits:type_name -> google.protobuf.FloatValue - 13, // 36: coder.tailnet.v2.TelemetryRequest.events:type_name -> coder.tailnet.v2.TelemetryEvent - 19, // 37: coder.tailnet.v2.DERPMap.HomeParams.region_score:type_name -> coder.tailnet.v2.DERPMap.HomeParams.RegionScoreEntry - 20, // 38: coder.tailnet.v2.DERPMap.Region.nodes:type_name -> coder.tailnet.v2.DERPMap.Region.Node - 17, // 39: coder.tailnet.v2.DERPMap.RegionsEntry.value:type_name -> coder.tailnet.v2.DERPMap.Region - 6, // 40: coder.tailnet.v2.CoordinateRequest.UpdateSelf.node:type_name -> coder.tailnet.v2.Node - 6, // 41: coder.tailnet.v2.CoordinateResponse.PeerUpdate.node:type_name -> coder.tailnet.v2.Node - 0, // 42: coder.tailnet.v2.CoordinateResponse.PeerUpdate.kind:type_name -> coder.tailnet.v2.CoordinateResponse.PeerUpdate.Kind - 33, // 43: coder.tailnet.v2.Netcheck.RegionV4LatencyEntry.value:type_name -> google.protobuf.Duration - 33, // 44: coder.tailnet.v2.Netcheck.RegionV6LatencyEntry.value:type_name -> google.protobuf.Duration - 11, // 45: coder.tailnet.v2.Netcheck.NetcheckIP.fields:type_name -> coder.tailnet.v2.IPFields - 11, // 46: coder.tailnet.v2.TelemetryEvent.P2PEndpoint.fields:type_name -> coder.tailnet.v2.IPFields - 14, // 47: coder.tailnet.v2.Tailnet.PostTelemetry:input_type -> coder.tailnet.v2.TelemetryRequest - 5, // 48: coder.tailnet.v2.Tailnet.StreamDERPMaps:input_type -> coder.tailnet.v2.StreamDERPMapsRequest - 7, // 49: coder.tailnet.v2.Tailnet.RefreshResumeToken:input_type -> coder.tailnet.v2.RefreshResumeTokenRequest - 9, // 50: coder.tailnet.v2.Tailnet.Coordinate:input_type -> coder.tailnet.v2.CoordinateRequest - 15, // 51: coder.tailnet.v2.Tailnet.PostTelemetry:output_type -> coder.tailnet.v2.TelemetryResponse - 4, // 52: coder.tailnet.v2.Tailnet.StreamDERPMaps:output_type -> coder.tailnet.v2.DERPMap - 8, // 53: coder.tailnet.v2.Tailnet.RefreshResumeToken:output_type -> coder.tailnet.v2.RefreshResumeTokenResponse - 10, // 54: coder.tailnet.v2.Tailnet.Coordinate:output_type -> coder.tailnet.v2.CoordinateResponse - 51, // [51:55] is the sub-list for method output_type - 47, // [47:51] is the sub-list for method input_type - 47, // [47:47] is the sub-list for extension type_name - 47, // [47:47] is the sub-list for extension extendee - 0, // [0:47] is the sub-list for field type_name + 36, // 27: coder.tailnet.v2.TelemetryEvent.p2p_endpoint:type_name -> coder.tailnet.v2.TelemetryEvent.P2PEndpoint + 5, // 28: coder.tailnet.v2.TelemetryEvent.derp_map:type_name -> coder.tailnet.v2.DERPMap + 13, // 29: coder.tailnet.v2.TelemetryEvent.latest_netcheck:type_name -> coder.tailnet.v2.Netcheck + 38, // 30: coder.tailnet.v2.TelemetryEvent.connection_age:type_name -> google.protobuf.Duration + 38, // 31: coder.tailnet.v2.TelemetryEvent.connection_setup:type_name -> google.protobuf.Duration + 38, // 32: coder.tailnet.v2.TelemetryEvent.p2p_setup:type_name -> google.protobuf.Duration + 38, // 33: coder.tailnet.v2.TelemetryEvent.derp_latency:type_name -> google.protobuf.Duration + 38, // 34: coder.tailnet.v2.TelemetryEvent.p2p_latency:type_name -> google.protobuf.Duration + 40, // 35: coder.tailnet.v2.TelemetryEvent.throughput_mbits:type_name -> google.protobuf.FloatValue + 14, // 36: coder.tailnet.v2.TelemetryRequest.events:type_name -> coder.tailnet.v2.TelemetryEvent + 19, // 37: coder.tailnet.v2.WorkspaceUpdate.upserted_workspaces:type_name -> coder.tailnet.v2.Workspace + 20, // 38: coder.tailnet.v2.WorkspaceUpdate.upserted_agents:type_name -> coder.tailnet.v2.Agent + 19, // 39: coder.tailnet.v2.WorkspaceUpdate.deleted_workspaces:type_name -> coder.tailnet.v2.Workspace + 20, // 40: coder.tailnet.v2.WorkspaceUpdate.deleted_agents:type_name -> coder.tailnet.v2.Agent + 4, // 41: coder.tailnet.v2.Workspace.status:type_name -> coder.tailnet.v2.Workspace.Status + 24, // 42: coder.tailnet.v2.DERPMap.HomeParams.region_score:type_name -> coder.tailnet.v2.DERPMap.HomeParams.RegionScoreEntry + 25, // 43: coder.tailnet.v2.DERPMap.Region.nodes:type_name -> coder.tailnet.v2.DERPMap.Region.Node + 22, // 44: coder.tailnet.v2.DERPMap.RegionsEntry.value:type_name -> coder.tailnet.v2.DERPMap.Region + 7, // 45: coder.tailnet.v2.CoordinateRequest.UpdateSelf.node:type_name -> coder.tailnet.v2.Node + 7, // 46: coder.tailnet.v2.CoordinateResponse.PeerUpdate.node:type_name -> coder.tailnet.v2.Node + 0, // 47: coder.tailnet.v2.CoordinateResponse.PeerUpdate.kind:type_name -> coder.tailnet.v2.CoordinateResponse.PeerUpdate.Kind + 38, // 48: coder.tailnet.v2.Netcheck.RegionV4LatencyEntry.value:type_name -> google.protobuf.Duration + 38, // 49: coder.tailnet.v2.Netcheck.RegionV6LatencyEntry.value:type_name -> google.protobuf.Duration + 12, // 50: coder.tailnet.v2.Netcheck.NetcheckIP.fields:type_name -> coder.tailnet.v2.IPFields + 12, // 51: coder.tailnet.v2.TelemetryEvent.P2PEndpoint.fields:type_name -> coder.tailnet.v2.IPFields + 15, // 52: coder.tailnet.v2.Tailnet.PostTelemetry:input_type -> coder.tailnet.v2.TelemetryRequest + 6, // 53: coder.tailnet.v2.Tailnet.StreamDERPMaps:input_type -> coder.tailnet.v2.StreamDERPMapsRequest + 8, // 54: coder.tailnet.v2.Tailnet.RefreshResumeToken:input_type -> coder.tailnet.v2.RefreshResumeTokenRequest + 10, // 55: coder.tailnet.v2.Tailnet.Coordinate:input_type -> coder.tailnet.v2.CoordinateRequest + 17, // 56: coder.tailnet.v2.Tailnet.WorkspaceUpdates:input_type -> coder.tailnet.v2.WorkspaceUpdatesRequest + 16, // 57: coder.tailnet.v2.Tailnet.PostTelemetry:output_type -> coder.tailnet.v2.TelemetryResponse + 5, // 58: coder.tailnet.v2.Tailnet.StreamDERPMaps:output_type -> coder.tailnet.v2.DERPMap + 9, // 59: coder.tailnet.v2.Tailnet.RefreshResumeToken:output_type -> coder.tailnet.v2.RefreshResumeTokenResponse + 11, // 60: coder.tailnet.v2.Tailnet.Coordinate:output_type -> coder.tailnet.v2.CoordinateResponse + 18, // 61: coder.tailnet.v2.Tailnet.WorkspaceUpdates:output_type -> coder.tailnet.v2.WorkspaceUpdate + 57, // [57:62] is the sub-list for method output_type + 52, // [52:57] is the sub-list for method input_type + 52, // [52:52] is the sub-list for extension type_name + 52, // [52:52] is the sub-list for extension extendee + 0, // [0:52] is the sub-list for field type_name } func init() { file_tailnet_proto_tailnet_proto_init() } @@ -2468,7 +2837,7 @@ func file_tailnet_proto_tailnet_proto_init() { } } file_tailnet_proto_tailnet_proto_msgTypes[12].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*DERPMap_HomeParams); i { + switch v := v.(*WorkspaceUpdatesRequest); i { case 0: return &v.state case 1: @@ -2480,7 +2849,31 @@ func file_tailnet_proto_tailnet_proto_init() { } } file_tailnet_proto_tailnet_proto_msgTypes[13].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*DERPMap_Region); i { + switch v := v.(*WorkspaceUpdate); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_tailnet_proto_tailnet_proto_msgTypes[14].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Workspace); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_tailnet_proto_tailnet_proto_msgTypes[15].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Agent); i { case 0: return &v.state case 1: @@ -2492,6 +2885,30 @@ func file_tailnet_proto_tailnet_proto_init() { } } file_tailnet_proto_tailnet_proto_msgTypes[16].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*DERPMap_HomeParams); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_tailnet_proto_tailnet_proto_msgTypes[17].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*DERPMap_Region); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_tailnet_proto_tailnet_proto_msgTypes[20].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*DERPMap_Region_Node); i { case 0: return &v.state @@ -2503,7 +2920,7 @@ func file_tailnet_proto_tailnet_proto_init() { return nil } } - file_tailnet_proto_tailnet_proto_msgTypes[19].Exporter = func(v interface{}, i int) interface{} { + file_tailnet_proto_tailnet_proto_msgTypes[23].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*CoordinateRequest_UpdateSelf); i { case 0: return &v.state @@ -2515,7 +2932,7 @@ func file_tailnet_proto_tailnet_proto_init() { return nil } } - file_tailnet_proto_tailnet_proto_msgTypes[20].Exporter = func(v interface{}, i int) interface{} { + file_tailnet_proto_tailnet_proto_msgTypes[24].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*CoordinateRequest_Disconnect); i { case 0: return &v.state @@ -2527,7 +2944,7 @@ func file_tailnet_proto_tailnet_proto_init() { return nil } } - file_tailnet_proto_tailnet_proto_msgTypes[21].Exporter = func(v interface{}, i int) interface{} { + file_tailnet_proto_tailnet_proto_msgTypes[25].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*CoordinateRequest_Tunnel); i { case 0: return &v.state @@ -2539,7 +2956,7 @@ func file_tailnet_proto_tailnet_proto_init() { return nil } } - file_tailnet_proto_tailnet_proto_msgTypes[22].Exporter = func(v interface{}, i int) interface{} { + file_tailnet_proto_tailnet_proto_msgTypes[26].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*CoordinateRequest_ReadyForHandshake); i { case 0: return &v.state @@ -2551,7 +2968,7 @@ func file_tailnet_proto_tailnet_proto_init() { return nil } } - file_tailnet_proto_tailnet_proto_msgTypes[23].Exporter = func(v interface{}, i int) interface{} { + file_tailnet_proto_tailnet_proto_msgTypes[27].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*CoordinateResponse_PeerUpdate); i { case 0: return &v.state @@ -2563,7 +2980,7 @@ func file_tailnet_proto_tailnet_proto_init() { return nil } } - file_tailnet_proto_tailnet_proto_msgTypes[26].Exporter = func(v interface{}, i int) interface{} { + file_tailnet_proto_tailnet_proto_msgTypes[30].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*Netcheck_NetcheckIP); i { case 0: return &v.state @@ -2575,7 +2992,7 @@ func file_tailnet_proto_tailnet_proto_init() { return nil } } - file_tailnet_proto_tailnet_proto_msgTypes[27].Exporter = func(v interface{}, i int) interface{} { + file_tailnet_proto_tailnet_proto_msgTypes[31].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*TelemetryEvent_P2PEndpoint); i { case 0: return &v.state @@ -2593,8 +3010,8 @@ func file_tailnet_proto_tailnet_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_tailnet_proto_tailnet_proto_rawDesc, - NumEnums: 4, - NumMessages: 28, + NumEnums: 5, + NumMessages: 32, NumExtensions: 0, NumServices: 1, }, diff --git a/tailnet/proto/tailnet.proto b/tailnet/proto/tailnet.proto index b375ead7c7b63..c7d770b9072bc 100644 --- a/tailnet/proto/tailnet.proto +++ b/tailnet/proto/tailnet.proto @@ -198,9 +198,45 @@ message TelemetryRequest { message TelemetryResponse {} +message WorkspaceUpdatesRequest {} + +message WorkspaceUpdate { + repeated Workspace upserted_workspaces = 1; + repeated Agent upserted_agents = 2; + repeated Workspace deleted_workspaces = 3; + repeated Agent deleted_agents = 4; +} + +message Workspace { + bytes id = 1; // UUID + string name = 2; + + enum Status { + UNKNOWN = 0; + PENDING = 1; + STARTING = 2; + RUNNING = 3; + STOPPING = 4; + STOPPED = 5; + FAILED = 6; + CANCELING = 7; + CANCELED = 8; + DELETING = 9; + DELETED = 10; + } + Status status = 3; +} + +message Agent { + bytes id = 1; // UUID + string name = 2; + bytes workspace_id = 3; // UUID +} + service Tailnet { rpc PostTelemetry(TelemetryRequest) returns (TelemetryResponse); rpc StreamDERPMaps(StreamDERPMapsRequest) returns (stream DERPMap); rpc RefreshResumeToken(RefreshResumeTokenRequest) returns (RefreshResumeTokenResponse); rpc Coordinate(stream CoordinateRequest) returns (stream CoordinateResponse); + rpc WorkspaceUpdates(WorkspaceUpdatesRequest) returns (stream WorkspaceUpdate); } diff --git a/tailnet/proto/tailnet_drpc.pb.go b/tailnet/proto/tailnet_drpc.pb.go index c0c3fcef65249..9dac4c06f3108 100644 --- a/tailnet/proto/tailnet_drpc.pb.go +++ b/tailnet/proto/tailnet_drpc.pb.go @@ -42,6 +42,7 @@ type DRPCTailnetClient interface { StreamDERPMaps(ctx context.Context, in *StreamDERPMapsRequest) (DRPCTailnet_StreamDERPMapsClient, error) RefreshResumeToken(ctx context.Context, in *RefreshResumeTokenRequest) (*RefreshResumeTokenResponse, error) Coordinate(ctx context.Context) (DRPCTailnet_CoordinateClient, error) + WorkspaceUpdates(ctx context.Context, in *WorkspaceUpdatesRequest) (DRPCTailnet_WorkspaceUpdatesClient, error) } type drpcTailnetClient struct { @@ -151,11 +152,52 @@ func (x *drpcTailnet_CoordinateClient) RecvMsg(m *CoordinateResponse) error { return x.MsgRecv(m, drpcEncoding_File_tailnet_proto_tailnet_proto{}) } +func (c *drpcTailnetClient) WorkspaceUpdates(ctx context.Context, in *WorkspaceUpdatesRequest) (DRPCTailnet_WorkspaceUpdatesClient, error) { + stream, err := c.cc.NewStream(ctx, "/coder.tailnet.v2.Tailnet/WorkspaceUpdates", drpcEncoding_File_tailnet_proto_tailnet_proto{}) + if err != nil { + return nil, err + } + x := &drpcTailnet_WorkspaceUpdatesClient{stream} + if err := x.MsgSend(in, drpcEncoding_File_tailnet_proto_tailnet_proto{}); err != nil { + return nil, err + } + if err := x.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +type DRPCTailnet_WorkspaceUpdatesClient interface { + drpc.Stream + Recv() (*WorkspaceUpdate, error) +} + +type drpcTailnet_WorkspaceUpdatesClient struct { + drpc.Stream +} + +func (x *drpcTailnet_WorkspaceUpdatesClient) GetStream() drpc.Stream { + return x.Stream +} + +func (x *drpcTailnet_WorkspaceUpdatesClient) Recv() (*WorkspaceUpdate, error) { + m := new(WorkspaceUpdate) + if err := x.MsgRecv(m, drpcEncoding_File_tailnet_proto_tailnet_proto{}); err != nil { + return nil, err + } + return m, nil +} + +func (x *drpcTailnet_WorkspaceUpdatesClient) RecvMsg(m *WorkspaceUpdate) error { + return x.MsgRecv(m, drpcEncoding_File_tailnet_proto_tailnet_proto{}) +} + type DRPCTailnetServer interface { PostTelemetry(context.Context, *TelemetryRequest) (*TelemetryResponse, error) StreamDERPMaps(*StreamDERPMapsRequest, DRPCTailnet_StreamDERPMapsStream) error RefreshResumeToken(context.Context, *RefreshResumeTokenRequest) (*RefreshResumeTokenResponse, error) Coordinate(DRPCTailnet_CoordinateStream) error + WorkspaceUpdates(*WorkspaceUpdatesRequest, DRPCTailnet_WorkspaceUpdatesStream) error } type DRPCTailnetUnimplementedServer struct{} @@ -176,9 +218,13 @@ func (s *DRPCTailnetUnimplementedServer) Coordinate(DRPCTailnet_CoordinateStream return drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented) } +func (s *DRPCTailnetUnimplementedServer) WorkspaceUpdates(*WorkspaceUpdatesRequest, DRPCTailnet_WorkspaceUpdatesStream) error { + return drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented) +} + type DRPCTailnetDescription struct{} -func (DRPCTailnetDescription) NumMethods() int { return 4 } +func (DRPCTailnetDescription) NumMethods() int { return 5 } func (DRPCTailnetDescription) Method(n int) (string, drpc.Encoding, drpc.Receiver, interface{}, bool) { switch n { @@ -217,6 +263,15 @@ func (DRPCTailnetDescription) Method(n int) (string, drpc.Encoding, drpc.Receive &drpcTailnet_CoordinateStream{in1.(drpc.Stream)}, ) }, DRPCTailnetServer.Coordinate, true + case 4: + return "/coder.tailnet.v2.Tailnet/WorkspaceUpdates", drpcEncoding_File_tailnet_proto_tailnet_proto{}, + func(srv interface{}, ctx context.Context, in1, in2 interface{}) (drpc.Message, error) { + return nil, srv.(DRPCTailnetServer). + WorkspaceUpdates( + in1.(*WorkspaceUpdatesRequest), + &drpcTailnet_WorkspaceUpdatesStream{in2.(drpc.Stream)}, + ) + }, DRPCTailnetServer.WorkspaceUpdates, true default: return "", nil, nil, nil, false } @@ -296,3 +351,16 @@ func (x *drpcTailnet_CoordinateStream) Recv() (*CoordinateRequest, error) { func (x *drpcTailnet_CoordinateStream) RecvMsg(m *CoordinateRequest) error { return x.MsgRecv(m, drpcEncoding_File_tailnet_proto_tailnet_proto{}) } + +type DRPCTailnet_WorkspaceUpdatesStream interface { + drpc.Stream + Send(*WorkspaceUpdate) error +} + +type drpcTailnet_WorkspaceUpdatesStream struct { + drpc.Stream +} + +func (x *drpcTailnet_WorkspaceUpdatesStream) Send(m *WorkspaceUpdate) error { + return x.MsgSend(m, drpcEncoding_File_tailnet_proto_tailnet_proto{}) +} diff --git a/tailnet/service.go b/tailnet/service.go index 7f38f63a589b3..0982ff0f629b2 100644 --- a/tailnet/service.go +++ b/tailnet/service.go @@ -39,13 +39,21 @@ func WithStreamID(ctx context.Context, streamID StreamID) context.Context { return context.WithValue(ctx, streamIDContextKey{}, streamID) } +type WorkspaceUpdatesProvider interface { + Subscribe(peerID uuid.UUID, userID uuid.UUID) (<-chan *proto.WorkspaceUpdate, error) + Unsubscribe(peerID uuid.UUID) + Stop() + OwnsAgent(userID uuid.UUID, agentID uuid.UUID) bool +} + type ClientServiceOptions struct { - Logger slog.Logger - CoordPtr *atomic.Pointer[Coordinator] - DERPMapUpdateFrequency time.Duration - DERPMapFn func() *tailcfg.DERPMap - NetworkTelemetryHandler func(batch []*proto.TelemetryEvent) - ResumeTokenProvider ResumeTokenProvider + Logger slog.Logger + CoordPtr *atomic.Pointer[Coordinator] + DERPMapUpdateFrequency time.Duration + DERPMapFn func() *tailcfg.DERPMap + NetworkTelemetryHandler func(batch []*proto.TelemetryEvent) + ResumeTokenProvider ResumeTokenProvider + WorkspaceUpdatesProvider WorkspaceUpdatesProvider } // ClientService is a tailnet coordination service that accepts a connection and version from a @@ -64,12 +72,13 @@ func NewClientService(options ClientServiceOptions) ( s := &ClientService{Logger: options.Logger, CoordPtr: options.CoordPtr} mux := drpcmux.New() drpcService := &DRPCService{ - CoordPtr: options.CoordPtr, - Logger: options.Logger, - DerpMapUpdateFrequency: options.DERPMapUpdateFrequency, - DerpMapFn: options.DERPMapFn, - NetworkTelemetryHandler: options.NetworkTelemetryHandler, - ResumeTokenProvider: options.ResumeTokenProvider, + CoordPtr: options.CoordPtr, + Logger: options.Logger, + DerpMapUpdateFrequency: options.DERPMapUpdateFrequency, + DerpMapFn: options.DERPMapFn, + NetworkTelemetryHandler: options.NetworkTelemetryHandler, + ResumeTokenProvider: options.ResumeTokenProvider, + WorkspaceUpdatesProvider: options.WorkspaceUpdatesProvider, } err := proto.DRPCRegisterTailnet(mux, drpcService) if err != nil { @@ -110,6 +119,36 @@ func (s *ClientService) ServeClient(ctx context.Context, version string, conn ne } } +type ServeUserClientOptions struct { + PeerID uuid.UUID + UserID uuid.UUID + UpdatesProvider WorkspaceUpdatesProvider +} + +func (s *ClientService) ServeUserClient(ctx context.Context, version string, conn net.Conn, opts ServeUserClientOptions) error { + major, _, err := apiversion.Parse(version) + if err != nil { + s.Logger.Warn(ctx, "serve client called with unparsable version", slog.Error(err)) + return err + } + switch major { + case 2: + auth := ClientUserCoordinateeAuth{ + UserID: opts.UserID, + UpdatesProvider: opts.UpdatesProvider, + } + streamID := StreamID{ + Name: "client", + ID: opts.PeerID, + Auth: auth, + } + return s.ServeConnV2(ctx, conn, streamID) + default: + s.Logger.Warn(ctx, "serve client called with unsupported version", slog.F("version", version)) + return ErrUnsupportedVersion + } +} + func (s ClientService) ServeConnV2(ctx context.Context, conn net.Conn, streamID StreamID) error { config := yamux.DefaultConfig() config.LogOutput = io.Discard @@ -125,12 +164,13 @@ func (s ClientService) ServeConnV2(ctx context.Context, conn net.Conn, streamID // DRPCService is the dRPC-based, version 2.x of the tailnet API and implements proto.DRPCClientServer type DRPCService struct { - CoordPtr *atomic.Pointer[Coordinator] - Logger slog.Logger - DerpMapUpdateFrequency time.Duration - DerpMapFn func() *tailcfg.DERPMap - NetworkTelemetryHandler func(batch []*proto.TelemetryEvent) - ResumeTokenProvider ResumeTokenProvider + CoordPtr *atomic.Pointer[Coordinator] + Logger slog.Logger + DerpMapUpdateFrequency time.Duration + DerpMapFn func() *tailcfg.DERPMap + NetworkTelemetryHandler func(batch []*proto.TelemetryEvent) + ResumeTokenProvider ResumeTokenProvider + WorkspaceUpdatesProvider WorkspaceUpdatesProvider } func (s *DRPCService) PostTelemetry(_ context.Context, req *proto.TelemetryRequest) (*proto.TelemetryResponse, error) { @@ -205,6 +245,51 @@ func (s *DRPCService) Coordinate(stream proto.DRPCTailnet_CoordinateStream) erro return nil } +func (s *DRPCService) WorkspaceUpdates(_ *proto.WorkspaceUpdatesRequest, stream proto.DRPCTailnet_WorkspaceUpdatesStream) error { + defer stream.Close() + + ctx := stream.Context() + streamID, ok := ctx.Value(streamIDContextKey{}).(StreamID) + if !ok { + _ = stream.Close() + return xerrors.New("no Stream ID") + } + + var ( + updatesCh <-chan *proto.WorkspaceUpdate + err error + ) + switch auth := streamID.Auth.(type) { + case ClientUserCoordinateeAuth: + // Stream ID is the peer ID + updatesCh, err = s.WorkspaceUpdatesProvider.Subscribe(streamID.ID, auth.UserID) + if err != nil { + err = xerrors.Errorf("subscribe to workspace updates: %w", err) + } + defer s.WorkspaceUpdatesProvider.Unsubscribe(streamID.ID) + default: + err = xerrors.Errorf("workspace updates not supported by auth name %T", auth) + } + if err != nil { + return err + } + + for { + select { + case updates := <-updatesCh: + if updates == nil { + return nil + } + err := stream.Send(updates) + if err != nil { + return xerrors.Errorf("send workspace update: %w", err) + } + case <-stream.Context().Done(): + return nil + } + } +} + type communicator struct { logger slog.Logger stream proto.DRPCTailnet_CoordinateStream diff --git a/tailnet/tunnel.go b/tailnet/tunnel.go index 3e55abb955513..86833bbd8f9f5 100644 --- a/tailnet/tunnel.go +++ b/tailnet/tunnel.go @@ -1,6 +1,7 @@ package tailnet import ( + "database/sql" "net/netip" "github.com/google/uuid" @@ -91,6 +92,42 @@ func (a AgentCoordinateeAuth) Authorize(req *proto.CoordinateRequest) error { return nil } +type ClientUserCoordinateeAuth struct { + UserID uuid.UUID + UpdatesProvider WorkspaceUpdatesProvider +} + +func (a ClientUserCoordinateeAuth) Authorize(req *proto.CoordinateRequest) error { + if tun := req.GetAddTunnel(); tun != nil { + uid, err := uuid.FromBytes(tun.Id) + if err != nil { + return xerrors.Errorf("parse add tunnel id: %w", err) + } + isOwner := a.UpdatesProvider.OwnsAgent(a.UserID, uid) + if !isOwner { + return xerrors.Errorf("workspace agent not found or you do not have permission: %w", sql.ErrNoRows) + } + } + + if upd := req.GetUpdateSelf(); upd != nil { + for _, addrStr := range upd.Node.Addresses { + pre, err := netip.ParsePrefix(addrStr) + if err != nil { + return xerrors.Errorf("parse node address: %w", err) + } + + if pre.Bits() != 128 { + return xerrors.Errorf("invalid address bits, expected 128, got %d", pre.Bits()) + } + } + } + + if rfh := req.GetReadyForHandshake(); rfh != nil { + return xerrors.Errorf("clients may not send ready_for_handshake") + } + return nil +} + // tunnelStore contains tunnel information and allows querying it. It is not threadsafe and all // methods must be serialized by holding, e.g. the core mutex. type tunnelStore struct { From 341c6881050884b116137edd3510bc6a0b222628 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 22 Oct 2024 09:40:32 +0000 Subject: [PATCH 2/7] review p1 --- cli/server.go | 2 +- coderd/apidoc/docs.go | 38 ++++---- coderd/apidoc/swagger.json | 34 +++---- coderd/coderd.go | 5 +- coderd/coderdtest/coderdtest.go | 4 +- coderd/workspaceagents.go | 39 +++++--- coderd/workspaceagents_test.go | 16 ++-- coderd/workspaceupdates.go | 127 ++++++++++++------------- coderd/workspaceupdates_test.go | 40 ++++---- docs/reference/api/agents.md | 6 +- enterprise/tailnet/connio.go | 2 +- tailnet/coordinator.go | 4 +- tailnet/coordinator_test.go | 10 +- tailnet/peer.go | 4 +- tailnet/proto/tailnet.pb.go | 163 +++++++++++++++++--------------- tailnet/proto/tailnet.proto | 4 +- tailnet/service.go | 91 +++++++++--------- tailnet/service_test.go | 12 ++- tailnet/tunnel.go | 18 ++-- 19 files changed, 328 insertions(+), 291 deletions(-) diff --git a/cli/server.go b/cli/server.go index 4abff00bb89f3..2e53974aca20b 100644 --- a/cli/server.go +++ b/cli/server.go @@ -733,7 +733,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. return xerrors.Errorf("create workspace updates provider: %w", err) } options.WorkspaceUpdatesProvider = wsUpdates - defer wsUpdates.Stop() + defer wsUpdates.Close() var deploymentID string err = options.Database.InTx(func(tx database.Store) error { diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 5a235d677d82b..a2a6ae2fcd2aa 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -3770,6 +3770,25 @@ const docTemplate = `{ } } }, + "/tailnet": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "tags": [ + "Agents" + ], + "summary": "User-scoped agent coordination", + "operationId": "user-scoped-agent-coordination", + "responses": { + "101": { + "description": "Switching Protocols" + } + } + } + }, "/templates": { "get": { "security": [ @@ -5232,25 +5251,6 @@ const docTemplate = `{ } } }, - "/users/me/tailnet": { - "get": { - "security": [ - { - "CoderSessionToken": [] - } - ], - "tags": [ - "Agents" - ], - "summary": "Coordinate multiple workspace agents", - "operationId": "coordinate-multiple-workspace-agents", - "responses": { - "101": { - "description": "Switching Protocols" - } - } - } - }, "/users/oauth2/github/callback": { "get": { "security": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 99e0a8326093a..acd036bf4e2b0 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -3316,6 +3316,23 @@ } } }, + "/tailnet": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "tags": ["Agents"], + "summary": "User-scoped agent coordination", + "operationId": "user-scoped-agent-coordination", + "responses": { + "101": { + "description": "Switching Protocols" + } + } + } + }, "/templates": { "get": { "security": [ @@ -4614,23 +4631,6 @@ } } }, - "/users/me/tailnet": { - "get": { - "security": [ - { - "CoderSessionToken": [] - } - ], - "tags": ["Agents"], - "summary": "Coordinate multiple workspace agents", - "operationId": "coordinate-multiple-workspace-agents", - "responses": { - "101": { - "description": "Switching Protocols" - } - } - } - }, "/users/oauth2/github/callback": { "get": { "security": [ diff --git a/coderd/coderd.go b/coderd/coderd.go index df89eef289fb5..e3f0cd4d8fe11 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1073,7 +1073,6 @@ func New(options *Options) *API { r.Route("/roles", func(r chi.Router) { r.Get("/", api.AssignableSiteRoles) }) - r.Get("/me/tailnet", api.tailnet) r.Route("/{user}", func(r chi.Router) { r.Use(httpmw.ExtractUserParam(options.Database)) r.Post("/convert-login", api.postConvertLoginType) @@ -1331,6 +1330,10 @@ func New(options *Options) *API { }) r.Get("/dispatch-methods", api.notificationDispatchMethods) }) + r.Route("/tailnet", func(r chi.Router) { + r.Use(apiKeyMiddleware) + r.Get("/", api.tailnet) + }) }) if options.SwaggerEndpoint { diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 69a2af1cce2cc..0f33628c50b25 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -260,7 +260,9 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can var err error options.WorkspaceUpdatesProvider, err = coderd.NewUpdatesProvider(options.Logger.Named("workspace_updates"), options.Database, options.Pubsub) require.NoError(t, err) - t.Cleanup(options.WorkspaceUpdatesProvider.Stop) + t.Cleanup(func() { + _ = options.WorkspaceUpdatesProvider.Close() + }) } accessControlStore := &atomic.Pointer[dbauthz.AccessControlStore]{} diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 0cfa020e3e662..b7b58b1ab5b56 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -33,6 +33,7 @@ import ( "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/jwtutils" + "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/wspubsub" "github.com/coder/coder/v2/codersdk" @@ -870,7 +871,10 @@ func (api *API) workspaceAgentClientCoordinate(rw http.ResponseWriter, r *http.R go httpapi.Heartbeat(ctx, conn) defer conn.Close(websocket.StatusNormalClosure, "") - err = api.TailnetClientService.ServeClient(ctx, version, wsNetConn, peerID, workspaceAgent.ID) + err = api.TailnetClientService.ServeClient(ctx, version, wsNetConn, tailnet.ServeClientOptions{ + Peer: peerID, + Agent: &workspaceAgent.ID, + }) if err != nil && !xerrors.Is(err, io.EOF) && !xerrors.Is(err, context.Canceled) { _ = conn.Close(websocket.StatusInternalError, err.Error()) return @@ -1475,21 +1479,14 @@ func (api *API) workspaceAgentsExternalAuthListen(ctx context.Context, rw http.R } } -// @Summary Coordinate multiple workspace agents -// @ID coordinate-multiple-workspace-agents +// @Summary User-scoped agent coordination +// @ID user-scoped-agent-coordination // @Security CoderSessionToken // @Tags Agents // @Success 101 -// @Router /users/me/tailnet [get] +// @Router /tailnet [get] func (api *API) tailnet(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() - apiKey, ok := httpmw.APIKeyOptional(r) - if !ok { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Cannot use \"me\" without a valid session.", - }) - return - } version := "2.0" qv := r.URL.Query().Get("version") @@ -1512,6 +1509,16 @@ func (api *API) tailnet(rw http.ResponseWriter, r *http.Request) { return } + // Used to authorize tunnel requests, and filter workspace update DB queries + prepared, err := api.HTTPAuth.AuthorizeSQLFilter(r, policy.ActionRead, rbac.ResourceWorkspace.Type) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error preparing sql filter.", + Detail: err.Error(), + }) + return + } + api.WebsocketWaitMutex.Lock() api.WebsocketWaitGroup.Add(1) api.WebsocketWaitMutex.Unlock() @@ -1530,10 +1537,12 @@ func (api *API) tailnet(rw http.ResponseWriter, r *http.Request) { defer conn.Close(websocket.StatusNormalClosure, "") go httpapi.Heartbeat(ctx, conn) - err = api.TailnetClientService.ServeUserClient(ctx, version, wsNetConn, tailnet.ServeUserClientOptions{ - PeerID: peerID, - UserID: apiKey.UserID, - UpdatesProvider: api.WorkspaceUpdatesProvider, + err = api.TailnetClientService.ServeClient(ctx, version, wsNetConn, tailnet.ServeClientOptions{ + Peer: peerID, + Auth: &tunnelAuthorizer{ + prep: prepared, + db: api.Database, + }, }) if err != nil && !xerrors.Is(err, io.EOF) && !xerrors.Is(err, context.Canceled) { _ = conn.Close(websocket.StatusInternalError, err.Error()) diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index aaaf1499bef95..a30dff20c611f 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -1943,13 +1943,13 @@ func TestOwnedWorkspacesCoordinate(t *testing.T) { }) defer closer.Close() firstUser := coderdtest.CreateFirstUser(t, firstClient) - user, _ := coderdtest.CreateAnotherUser(t, firstClient, firstUser.OrganizationID, rbac.RoleTemplateAdmin()) + member, memberUser := coderdtest.CreateAnotherUser(t, firstClient, firstUser.OrganizationID, rbac.RoleTemplateAdmin()) // Create a workspace token := uuid.NewString() - resources, _ := buildWorkspaceWithAgent(t, user, firstUser.OrganizationID, token) + resources, _ := buildWorkspaceWithAgent(t, member, firstUser.OrganizationID, token) - u, err := user.URL.Parse("/api/v2/users/me/tailnet") + u, err := member.URL.Parse("/api/v2/tailnet") require.NoError(t, err) q := u.Query() q.Set("version", "2.0") @@ -1958,7 +1958,7 @@ func TestOwnedWorkspacesCoordinate(t *testing.T) { //nolint:bodyclose // websocket package closes this for you wsConn, resp, err := websocket.Dial(ctx, u.String(), &websocket.DialOptions{ HTTPHeader: http.Header{ - "Coder-Session-Token": []string{user.SessionToken()}, + "Coder-Session-Token": []string{member.SessionToken()}, }, }) if err != nil { @@ -1975,7 +1975,9 @@ func TestOwnedWorkspacesCoordinate(t *testing.T) { ) require.NoError(t, err) - stream, err := rpcClient.WorkspaceUpdates(ctx, &tailnetproto.WorkspaceUpdatesRequest{}) + stream, err := rpcClient.WorkspaceUpdates(ctx, &tailnetproto.WorkspaceUpdatesRequest{ + WorkspaceOwnerId: tailnet.UUIDToByteSlice(memberUser.ID), + }) require.NoError(t, err) // Existing workspace @@ -1995,7 +1997,7 @@ func TestOwnedWorkspacesCoordinate(t *testing.T) { // Build a second workspace secondToken := uuid.NewString() - secondResources, secondWorkspace := buildWorkspaceWithAgent(t, user, firstUser.OrganizationID, secondToken) + secondResources, secondWorkspace := buildWorkspaceWithAgent(t, member, firstUser.OrganizationID, secondToken) // Workspace starting update, err = stream.Recv() @@ -2020,7 +2022,7 @@ func TestOwnedWorkspacesCoordinate(t *testing.T) { require.Len(t, update.DeletedWorkspaces, 0) require.Len(t, update.DeletedAgents, 0) - _, err = user.CreateWorkspaceBuild(ctx, secondWorkspace.ID, codersdk.CreateWorkspaceBuildRequest{ + _, err = member.CreateWorkspaceBuild(ctx, secondWorkspace.ID, codersdk.CreateWorkspaceBuildRequest{ Transition: codersdk.WorkspaceTransitionDelete, }) require.NoError(t, err) diff --git a/coderd/workspaceupdates.go b/coderd/workspaceupdates.go index c5ee4055955b3..ecf766aee9ad1 100644 --- a/coderd/workspaceupdates.go +++ b/coderd/workspaceupdates.go @@ -12,6 +12,7 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/pubsub" + "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/coderd/wspubsub" "github.com/coder/coder/v2/codersdk" @@ -34,9 +35,11 @@ func (w ownedWorkspace) Equal(other ownedWorkspace) bool { } type sub struct { + ctx context.Context mu sync.RWMutex userID uuid.UUID tx chan<- *proto.WorkspaceUpdate + rx <-chan *proto.WorkspaceUpdate prev workspacesByID db UpdateQuerier @@ -46,21 +49,14 @@ type sub struct { cancelFn func() } -func (s *sub) ownsAgent(agentID uuid.UUID) bool { - s.mu.RLock() - defer s.mu.RUnlock() - - for _, workspace := range s.prev { - for _, a := range workspace.Agents { - if a.ID == agentID { - return true - } - } +func (s *sub) handleEvent(ctx context.Context, event wspubsub.WorkspaceEvent, err error) { + select { + case <-ctx.Done(): + _ = s.Close() + return + default: } - return false -} -func (s *sub) handleEvent(ctx context.Context, event wspubsub.WorkspaceEvent) { s.mu.Lock() defer s.mu.Unlock() @@ -70,10 +66,15 @@ func (s *sub) handleEvent(ctx context.Context, event wspubsub.WorkspaceEvent) { case wspubsub.WorkspaceEventKindAgentTimeout: case wspubsub.WorkspaceEventKindAgentLifecycleUpdate: default: - return + if err == nil { + return + } else { + // Always attempt an update if the pubsub lost connection + s.logger.Warn(ctx, "failed to handle workspace event", slog.Error(err)) + } } - row, err := s.db.GetWorkspacesAndAgentsByOwnerID(context.Background(), s.userID) + row, err := s.db.GetWorkspacesAndAgentsByOwnerID(ctx, s.userID) if err != nil { s.logger.Warn(ctx, "failed to get workspaces and agents by owner ID", slog.Error(err)) } @@ -88,11 +89,11 @@ func (s *sub) handleEvent(ctx context.Context, event wspubsub.WorkspaceEvent) { s.tx <- out } -func (s *sub) start() (err error) { +func (s *sub) start(ctx context.Context) (err error) { s.mu.Lock() defer s.mu.Unlock() - rows, err := s.db.GetWorkspacesAndAgentsByOwnerID(context.Background(), s.userID) + rows, err := s.db.GetWorkspacesAndAgentsByOwnerID(ctx, s.userID) if err != nil { return xerrors.Errorf("get workspaces and agents by owner ID: %w", err) } @@ -102,7 +103,7 @@ func (s *sub) start() (err error) { s.tx <- initUpdate s.prev = latest - cancel, err := s.ps.Subscribe(wspubsub.WorkspaceEventChannel(s.userID), wspubsub.HandleWorkspaceEvent(s.logger, s.handleEvent)) + cancel, err := s.ps.SubscribeWithErr(wspubsub.WorkspaceEventChannel(s.userID), wspubsub.HandleWorkspaceEvent(s.handleEvent)) if err != nil { return xerrors.Errorf("subscribe to workspace event channel: %w", err) } @@ -111,7 +112,7 @@ func (s *sub) start() (err error) { return nil } -func (s *sub) stop() { +func (s *sub) Close() error { s.mu.Lock() defer s.mu.Unlock() @@ -120,85 +121,66 @@ func (s *sub) stop() { } close(s.tx) + return nil } +func (s *sub) Updates() <-chan *proto.WorkspaceUpdate { + return s.rx +} + +var _ tailnet.Subscription = (*sub)(nil) + type UpdateQuerier interface { GetWorkspacesAndAgentsByOwnerID(ctx context.Context, ownerID uuid.UUID) ([]database.GetWorkspacesAndAgentsByOwnerIDRow, error) } type updatesProvider struct { - mu sync.RWMutex - // Peer ID -> subscription - subs map[uuid.UUID]*sub - db UpdateQuerier ps pubsub.Pubsub logger slog.Logger -} - -func (u *updatesProvider) OwnsAgent(userID uuid.UUID, agentID uuid.UUID) bool { - u.mu.RLock() - defer u.mu.RUnlock() - for _, sub := range u.subs { - if sub.userID == userID && sub.ownsAgent(agentID) { - return true - } - } - return false + ctx context.Context + cancelFn func() } var _ tailnet.WorkspaceUpdatesProvider = (*updatesProvider)(nil) func NewUpdatesProvider(logger slog.Logger, db UpdateQuerier, ps pubsub.Pubsub) (tailnet.WorkspaceUpdatesProvider, error) { + ctx, cancel := context.WithCancel(context.Background()) out := &updatesProvider{ - db: db, - ps: ps, - logger: logger, - subs: map[uuid.UUID]*sub{}, + ctx: ctx, + cancelFn: cancel, + db: db, + ps: ps, + logger: logger, } return out, nil } -func (u *updatesProvider) Stop() { - for _, sub := range u.subs { - sub.stop() - } +func (u *updatesProvider) Close() error { + u.cancelFn() + return nil } -func (u *updatesProvider) Subscribe(peerID uuid.UUID, userID uuid.UUID) (<-chan *proto.WorkspaceUpdate, error) { - u.mu.Lock() - defer u.mu.Unlock() - - tx := make(chan *proto.WorkspaceUpdate, 1) +func (u *updatesProvider) Subscribe(ctx context.Context, userID uuid.UUID) (tailnet.Subscription, error) { + ch := make(chan *proto.WorkspaceUpdate, 1) sub := &sub{ + ctx: u.ctx, userID: userID, - tx: tx, + tx: ch, + rx: ch, db: u.db, ps: u.ps, - logger: u.logger.Named(fmt.Sprintf("workspace_updates_subscriber_%s", peerID)), + logger: u.logger.Named(fmt.Sprintf("workspace_updates_subscriber_%s", userID)), prev: workspacesByID{}, } - err := sub.start() + err := sub.start(ctx) if err != nil { - sub.stop() + _ = sub.Close() return nil, err } - u.subs[peerID] = sub - return tx, nil -} - -func (u *updatesProvider) Unsubscribe(peerID uuid.UUID) { - u.mu.Lock() - defer u.mu.Unlock() - - sub, exists := u.subs[peerID] - if !exists { - return - } - sub.stop() - delete(u.subs, peerID) + return sub, nil } func produceUpdate(old, new workspacesByID) (out *proto.WorkspaceUpdate, updated bool) { @@ -297,3 +279,18 @@ func convertRows(rows []database.GetWorkspacesAndAgentsByOwnerIDRow) workspacesB } return out } + +type tunnelAuthorizer struct { + prep rbac.PreparedAuthorized + db database.Store +} + +func (t *tunnelAuthorizer) AuthorizeByID(ctx context.Context, workspaceID uuid.UUID) error { + ws, err := t.db.GetWorkspaceByID(ctx, workspaceID) + if err != nil { + return xerrors.Errorf("get workspace by ID: %w", err) + } + return t.prep.Authorize(ctx, ws.RBACObject()) +} + +var _ tailnet.TunnelAuthorizer = (*tunnelAuthorizer)(nil) diff --git a/coderd/workspaceupdates_test.go b/coderd/workspaceupdates_test.go index fa267596a6b44..8f2b97f81bf8b 100644 --- a/coderd/workspaceupdates_test.go +++ b/coderd/workspaceupdates_test.go @@ -24,8 +24,6 @@ func TestWorkspaceUpdates(t *testing.T) { t.Parallel() ctx := context.Background() - peerID := uuid.New() - ws1ID := uuid.New() ws1IDSlice := tailnet.UUIDToByteSlice(ws1ID) agent1ID := uuid.New() @@ -76,15 +74,18 @@ func TestWorkspaceUpdates(t *testing.T) { } ps := &mockPubsub{ - cbs: map[string]pubsub.Listener{}, + cbs: map[string]pubsub.ListenerWithErr{}, } updateProvider, err := coderd.NewUpdatesProvider(slogtest.Make(t, nil), db, ps) - defer updateProvider.Stop() require.NoError(t, err) + t.Cleanup(func() { + _ = updateProvider.Close() + }) - ch, err := updateProvider.Subscribe(peerID, ownerID) + sub, err := updateProvider.Subscribe(ctx, ownerID) require.NoError(t, err) + ch := sub.Updates() update, ok := <-ch require.True(t, ok) @@ -215,15 +216,18 @@ func TestWorkspaceUpdates(t *testing.T) { } ps := &mockPubsub{ - cbs: map[string]pubsub.Listener{}, + cbs: map[string]pubsub.ListenerWithErr{}, } updateProvider, err := coderd.NewUpdatesProvider(slogtest.Make(t, nil), db, ps) - defer updateProvider.Stop() require.NoError(t, err) + t.Cleanup(func() { + _ = updateProvider.Close() + }) - ch, err := updateProvider.Subscribe(peerID, ownerID) + sub, err := updateProvider.Subscribe(ctx, ownerID) require.NoError(t, err) + ch := sub.Updates() expected := &proto.WorkspaceUpdate{ UpsertedWorkspaces: []*proto.Workspace{ @@ -250,10 +254,10 @@ func TestWorkspaceUpdates(t *testing.T) { }) require.Equal(t, expected, update) - updateProvider.Unsubscribe(ownerID) require.NoError(t, err) - ch, err = updateProvider.Subscribe(peerID, ownerID) + sub, err = updateProvider.Subscribe(ctx, ownerID) require.NoError(t, err) + ch = sub.Updates() update = testutil.RequireRecvCtx(ctx, t, ch) slices.SortFunc(update.UpsertedWorkspaces, func(a, b *proto.Workspace) int { @@ -281,7 +285,7 @@ func (m *mockWorkspaceStore) GetWorkspacesAndAgentsByOwnerID(context.Context, uu var _ coderd.UpdateQuerier = (*mockWorkspaceStore)(nil) type mockPubsub struct { - cbs map[string]pubsub.Listener + cbs map[string]pubsub.ListenerWithErr } // Close implements pubsub.Pubsub. @@ -295,19 +299,17 @@ func (m *mockPubsub) Publish(event string, message []byte) error { if !ok { return nil } - cb(context.Background(), message) + cb(context.Background(), message, nil) return nil } -// Subscribe implements pubsub.Pubsub. -func (m *mockPubsub) Subscribe(event string, listener pubsub.Listener) (cancel func(), err error) { - m.cbs[event] = listener - return func() {}, nil +func (*mockPubsub) Subscribe(string, pubsub.Listener) (cancel func(), err error) { + panic("unimplemented") } -// SubscribeWithErr implements pubsub.Pubsub. -func (*mockPubsub) SubscribeWithErr(string, pubsub.ListenerWithErr) (func(), error) { - panic("unimplemented") +func (m *mockPubsub) SubscribeWithErr(event string, listener pubsub.ListenerWithErr) (func(), error) { + m.cbs[event] = listener + return func() {}, nil } var _ pubsub.Pubsub = (*mockPubsub)(nil) diff --git a/docs/reference/api/agents.md b/docs/reference/api/agents.md index d3e3f5775c192..44cb0fa154ae5 100644 --- a/docs/reference/api/agents.md +++ b/docs/reference/api/agents.md @@ -20,17 +20,17 @@ curl -X GET http://coder-server:8080/api/v2/derp-map \ To perform this operation, you must be authenticated. [Learn more](authentication.md). -## Coordinate multiple workspace agents +## User-scoped agent coordination ### Code samples ```shell # Example request using curl -curl -X GET http://coder-server:8080/api/v2/users/me/tailnet \ +curl -X GET http://coder-server:8080/api/v2/tailnet \ -H 'Coder-Session-Token: API_KEY' ``` -`GET /users/me/tailnet` +`GET /tailnet` ### Responses diff --git a/enterprise/tailnet/connio.go b/enterprise/tailnet/connio.go index fd2c99bdeb8eb..17f397aa5f1d1 100644 --- a/enterprise/tailnet/connio.go +++ b/enterprise/tailnet/connio.go @@ -133,7 +133,7 @@ var errDisconnect = xerrors.New("graceful disconnect") func (c *connIO) handleRequest(req *proto.CoordinateRequest) error { c.logger.Debug(c.peerCtx, "got request") - err := c.auth.Authorize(req) + err := c.auth.Authorize(c.coordCtx, req) if err != nil { c.logger.Warn(c.peerCtx, "unauthorized request", slog.Error(err)) return xerrors.Errorf("authorize request: %w", err) diff --git a/tailnet/coordinator.go b/tailnet/coordinator.go index 54ce868df9316..b0592598959f3 100644 --- a/tailnet/coordinator.go +++ b/tailnet/coordinator.go @@ -566,7 +566,7 @@ func (c *core) node(id uuid.UUID) *Node { return v1Node } -func (c *core) handleRequest(p *peer, req *proto.CoordinateRequest) error { +func (c *core) handleRequest(ctx context.Context, p *peer, req *proto.CoordinateRequest) error { c.mutex.Lock() defer c.mutex.Unlock() if c.closed { @@ -577,7 +577,7 @@ func (c *core) handleRequest(p *peer, req *proto.CoordinateRequest) error { return ErrAlreadyRemoved } - if err := pr.auth.Authorize(req); err != nil { + if err := pr.auth.Authorize(ctx, req); err != nil { return xerrors.Errorf("authorize request: %w", err) } diff --git a/tailnet/coordinator_test.go b/tailnet/coordinator_test.go index 5ffffde8249a4..5d4ec51008920 100644 --- a/tailnet/coordinator_test.go +++ b/tailnet/coordinator_test.go @@ -328,7 +328,10 @@ func TestRemoteCoordination(t *testing.T) { serveErr := make(chan error, 1) go func() { - err := svc.ServeClient(ctx, proto.CurrentVersion.String(), sC, clientID, agentID) + err := svc.ServeClient(ctx, proto.CurrentVersion.String(), sC, tailnet.ServeClientOptions{ + Peer: clientID, + Agent: &agentID, + }) serveErr <- err }() @@ -377,7 +380,10 @@ func TestRemoteCoordination_SendsReadyForHandshake(t *testing.T) { serveErr := make(chan error, 1) go func() { - err := svc.ServeClient(ctx, proto.CurrentVersion.String(), sC, clientID, agentID) + err := svc.ServeClient(ctx, proto.CurrentVersion.String(), sC, tailnet.ServeClientOptions{ + Peer: clientID, + Agent: &agentID, + }) serveErr <- err }() diff --git a/tailnet/peer.go b/tailnet/peer.go index eadc882f5a6d6..7d69764abe103 100644 --- a/tailnet/peer.go +++ b/tailnet/peer.go @@ -121,7 +121,7 @@ func (p *peer) storeMappingLocked( }, nil } -func (p *peer) reqLoop(ctx context.Context, logger slog.Logger, handler func(*peer, *proto.CoordinateRequest) error) { +func (p *peer) reqLoop(ctx context.Context, logger slog.Logger, handler func(context.Context, *peer, *proto.CoordinateRequest) error) { for { select { case <-ctx.Done(): @@ -133,7 +133,7 @@ func (p *peer) reqLoop(ctx context.Context, logger slog.Logger, handler func(*pe return } logger.Debug(ctx, "peerReadLoop got request") - if err := handler(p, req); err != nil { + if err := handler(ctx, p, req); err != nil { if xerrors.Is(err, ErrAlreadyRemoved) || xerrors.Is(err, ErrClosed) { return } diff --git a/tailnet/proto/tailnet.pb.go b/tailnet/proto/tailnet.pb.go index 78816f6da3429..b2a03fa53f5d1 100644 --- a/tailnet/proto/tailnet.pb.go +++ b/tailnet/proto/tailnet.pb.go @@ -1251,6 +1251,8 @@ type WorkspaceUpdatesRequest struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields + + WorkspaceOwnerId []byte `protobuf:"bytes,1,opt,name=workspace_owner_id,json=workspaceOwnerId,proto3" json:"workspace_owner_id,omitempty"` // UUID } func (x *WorkspaceUpdatesRequest) Reset() { @@ -1285,6 +1287,13 @@ func (*WorkspaceUpdatesRequest) Descriptor() ([]byte, []int) { return file_tailnet_proto_tailnet_proto_rawDescGZIP(), []int{12} } +func (x *WorkspaceUpdatesRequest) GetWorkspaceOwnerId() []byte { + if x != nil { + return x.WorkspaceOwnerId + } + return nil +} + type WorkspaceUpdate struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -2479,84 +2488,86 @@ var file_tailnet_proto_tailnet_proto_rawDesc = []byte{ 0x2e, 0x74, 0x61, 0x69, 0x6c, 0x6e, 0x65, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x54, 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x74, 0x72, 0x79, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x52, 0x06, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x22, 0x13, 0x0a, 0x11, 0x54, 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x74, 0x72, 0x79, 0x52, - 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x19, 0x0a, 0x17, 0x57, 0x6f, 0x72, 0x6b, 0x73, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x47, 0x0a, 0x17, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x22, 0xad, 0x02, 0x0a, 0x0f, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, - 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x4c, 0x0a, 0x13, 0x75, 0x70, 0x73, 0x65, 0x72, 0x74, - 0x65, 0x64, 0x5f, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x73, 0x18, 0x01, 0x20, - 0x03, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x74, 0x61, 0x69, 0x6c, - 0x6e, 0x65, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, - 0x52, 0x12, 0x75, 0x70, 0x73, 0x65, 0x72, 0x74, 0x65, 0x64, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, - 0x61, 0x63, 0x65, 0x73, 0x12, 0x40, 0x0a, 0x0f, 0x75, 0x70, 0x73, 0x65, 0x72, 0x74, 0x65, 0x64, - 0x5f, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x17, 0x2e, + 0x73, 0x74, 0x12, 0x2c, 0x0a, 0x12, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, + 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x10, + 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x49, 0x64, + 0x22, 0xad, 0x02, 0x0a, 0x0f, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x55, 0x70, + 0x64, 0x61, 0x74, 0x65, 0x12, 0x4c, 0x0a, 0x13, 0x75, 0x70, 0x73, 0x65, 0x72, 0x74, 0x65, 0x64, + 0x5f, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, + 0x0b, 0x32, 0x1b, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x74, 0x61, 0x69, 0x6c, 0x6e, 0x65, + 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x52, 0x12, + 0x75, 0x70, 0x73, 0x65, 0x72, 0x74, 0x65, 0x64, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, + 0x65, 0x73, 0x12, 0x40, 0x0a, 0x0f, 0x75, 0x70, 0x73, 0x65, 0x72, 0x74, 0x65, 0x64, 0x5f, 0x61, + 0x67, 0x65, 0x6e, 0x74, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x63, 0x6f, + 0x64, 0x65, 0x72, 0x2e, 0x74, 0x61, 0x69, 0x6c, 0x6e, 0x65, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x41, + 0x67, 0x65, 0x6e, 0x74, 0x52, 0x0e, 0x75, 0x70, 0x73, 0x65, 0x72, 0x74, 0x65, 0x64, 0x41, 0x67, + 0x65, 0x6e, 0x74, 0x73, 0x12, 0x4a, 0x0a, 0x12, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x5f, + 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, + 0x32, 0x1b, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x74, 0x61, 0x69, 0x6c, 0x6e, 0x65, 0x74, + 0x2e, 0x76, 0x32, 0x2e, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x52, 0x11, 0x64, + 0x65, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x73, + 0x12, 0x3e, 0x0a, 0x0e, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x67, 0x65, 0x6e, + 0x74, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, + 0x2e, 0x74, 0x61, 0x69, 0x6c, 0x6e, 0x65, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x41, 0x67, 0x65, 0x6e, + 0x74, 0x52, 0x0d, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x73, + 0x22, 0x8a, 0x02, 0x0a, 0x09, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x12, 0x0e, + 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x02, 0x69, 0x64, 0x12, 0x12, + 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, + 0x6d, 0x65, 0x12, 0x3a, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x03, 0x20, 0x01, + 0x28, 0x0e, 0x32, 0x22, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x74, 0x61, 0x69, 0x6c, 0x6e, + 0x65, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x2e, + 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x22, 0x9c, + 0x01, 0x0a, 0x06, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x0b, 0x0a, 0x07, 0x55, 0x4e, 0x4b, + 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x0b, 0x0a, 0x07, 0x50, 0x45, 0x4e, 0x44, 0x49, 0x4e, + 0x47, 0x10, 0x01, 0x12, 0x0c, 0x0a, 0x08, 0x53, 0x54, 0x41, 0x52, 0x54, 0x49, 0x4e, 0x47, 0x10, + 0x02, 0x12, 0x0b, 0x0a, 0x07, 0x52, 0x55, 0x4e, 0x4e, 0x49, 0x4e, 0x47, 0x10, 0x03, 0x12, 0x0c, + 0x0a, 0x08, 0x53, 0x54, 0x4f, 0x50, 0x50, 0x49, 0x4e, 0x47, 0x10, 0x04, 0x12, 0x0b, 0x0a, 0x07, + 0x53, 0x54, 0x4f, 0x50, 0x50, 0x45, 0x44, 0x10, 0x05, 0x12, 0x0a, 0x0a, 0x06, 0x46, 0x41, 0x49, + 0x4c, 0x45, 0x44, 0x10, 0x06, 0x12, 0x0d, 0x0a, 0x09, 0x43, 0x41, 0x4e, 0x43, 0x45, 0x4c, 0x49, + 0x4e, 0x47, 0x10, 0x07, 0x12, 0x0c, 0x0a, 0x08, 0x43, 0x41, 0x4e, 0x43, 0x45, 0x4c, 0x45, 0x44, + 0x10, 0x08, 0x12, 0x0c, 0x0a, 0x08, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x49, 0x4e, 0x47, 0x10, 0x09, + 0x12, 0x0b, 0x0a, 0x07, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45, 0x44, 0x10, 0x0a, 0x22, 0x4e, 0x0a, + 0x05, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x0c, 0x52, 0x02, 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x77, 0x6f, + 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, + 0x52, 0x0b, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x49, 0x64, 0x32, 0xed, 0x03, + 0x0a, 0x07, 0x54, 0x61, 0x69, 0x6c, 0x6e, 0x65, 0x74, 0x12, 0x58, 0x0a, 0x0d, 0x50, 0x6f, 0x73, + 0x74, 0x54, 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x74, 0x72, 0x79, 0x12, 0x22, 0x2e, 0x63, 0x6f, 0x64, + 0x65, 0x72, 0x2e, 0x74, 0x61, 0x69, 0x6c, 0x6e, 0x65, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x54, 0x65, + 0x6c, 0x65, 0x6d, 0x65, 0x74, 0x72, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, + 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x74, 0x61, 0x69, 0x6c, 0x6e, 0x65, 0x74, 0x2e, 0x76, + 0x32, 0x2e, 0x54, 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x74, 0x72, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x12, 0x56, 0x0a, 0x0e, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x44, 0x45, 0x52, + 0x50, 0x4d, 0x61, 0x70, 0x73, 0x12, 0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x74, 0x61, + 0x69, 0x6c, 0x6e, 0x65, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x44, + 0x45, 0x52, 0x50, 0x4d, 0x61, 0x70, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x19, + 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x74, 0x61, 0x69, 0x6c, 0x6e, 0x65, 0x74, 0x2e, 0x76, + 0x32, 0x2e, 0x44, 0x45, 0x52, 0x50, 0x4d, 0x61, 0x70, 0x30, 0x01, 0x12, 0x6f, 0x0a, 0x12, 0x52, + 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x52, 0x65, 0x73, 0x75, 0x6d, 0x65, 0x54, 0x6f, 0x6b, 0x65, + 0x6e, 0x12, 0x2b, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x74, 0x61, 0x69, 0x6c, 0x6e, 0x65, + 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x52, 0x65, 0x73, 0x75, + 0x6d, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2c, + 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x74, 0x61, 0x69, 0x6c, 0x6e, 0x65, 0x74, 0x2e, 0x76, + 0x32, 0x2e, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x52, 0x65, 0x73, 0x75, 0x6d, 0x65, 0x54, + 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5b, 0x0a, 0x0a, + 0x43, 0x6f, 0x6f, 0x72, 0x64, 0x69, 0x6e, 0x61, 0x74, 0x65, 0x12, 0x23, 0x2e, 0x63, 0x6f, 0x64, + 0x65, 0x72, 0x2e, 0x74, 0x61, 0x69, 0x6c, 0x6e, 0x65, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x43, 0x6f, + 0x6f, 0x72, 0x64, 0x69, 0x6e, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, + 0x24, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x74, 0x61, 0x69, 0x6c, 0x6e, 0x65, 0x74, 0x2e, + 0x76, 0x32, 0x2e, 0x43, 0x6f, 0x6f, 0x72, 0x64, 0x69, 0x6e, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x28, 0x01, 0x30, 0x01, 0x12, 0x62, 0x0a, 0x10, 0x57, 0x6f, 0x72, + 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x73, 0x12, 0x29, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x74, 0x61, 0x69, 0x6c, 0x6e, 0x65, 0x74, 0x2e, 0x76, 0x32, - 0x2e, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, 0x0e, 0x75, 0x70, 0x73, 0x65, 0x72, 0x74, 0x65, 0x64, - 0x41, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x12, 0x4a, 0x0a, 0x12, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, - 0x64, 0x5f, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, - 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x74, 0x61, 0x69, 0x6c, 0x6e, - 0x65, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x52, - 0x11, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, - 0x65, 0x73, 0x12, 0x3e, 0x0a, 0x0e, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x67, - 0x65, 0x6e, 0x74, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x63, 0x6f, 0x64, - 0x65, 0x72, 0x2e, 0x74, 0x61, 0x69, 0x6c, 0x6e, 0x65, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x41, 0x67, - 0x65, 0x6e, 0x74, 0x52, 0x0d, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x41, 0x67, 0x65, 0x6e, - 0x74, 0x73, 0x22, 0x8a, 0x02, 0x0a, 0x09, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, - 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x02, 0x69, 0x64, - 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, - 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x3a, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x03, - 0x20, 0x01, 0x28, 0x0e, 0x32, 0x22, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x74, 0x61, 0x69, - 0x6c, 0x6e, 0x65, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, - 0x65, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, - 0x22, 0x9c, 0x01, 0x0a, 0x06, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x0b, 0x0a, 0x07, 0x55, - 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x0b, 0x0a, 0x07, 0x50, 0x45, 0x4e, 0x44, - 0x49, 0x4e, 0x47, 0x10, 0x01, 0x12, 0x0c, 0x0a, 0x08, 0x53, 0x54, 0x41, 0x52, 0x54, 0x49, 0x4e, - 0x47, 0x10, 0x02, 0x12, 0x0b, 0x0a, 0x07, 0x52, 0x55, 0x4e, 0x4e, 0x49, 0x4e, 0x47, 0x10, 0x03, - 0x12, 0x0c, 0x0a, 0x08, 0x53, 0x54, 0x4f, 0x50, 0x50, 0x49, 0x4e, 0x47, 0x10, 0x04, 0x12, 0x0b, - 0x0a, 0x07, 0x53, 0x54, 0x4f, 0x50, 0x50, 0x45, 0x44, 0x10, 0x05, 0x12, 0x0a, 0x0a, 0x06, 0x46, - 0x41, 0x49, 0x4c, 0x45, 0x44, 0x10, 0x06, 0x12, 0x0d, 0x0a, 0x09, 0x43, 0x41, 0x4e, 0x43, 0x45, - 0x4c, 0x49, 0x4e, 0x47, 0x10, 0x07, 0x12, 0x0c, 0x0a, 0x08, 0x43, 0x41, 0x4e, 0x43, 0x45, 0x4c, - 0x45, 0x44, 0x10, 0x08, 0x12, 0x0c, 0x0a, 0x08, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x49, 0x4e, 0x47, - 0x10, 0x09, 0x12, 0x0b, 0x0a, 0x07, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45, 0x44, 0x10, 0x0a, 0x22, - 0x4e, 0x0a, 0x05, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x0c, 0x52, 0x02, 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x21, 0x0a, 0x0c, - 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, - 0x28, 0x0c, 0x52, 0x0b, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x49, 0x64, 0x32, - 0xed, 0x03, 0x0a, 0x07, 0x54, 0x61, 0x69, 0x6c, 0x6e, 0x65, 0x74, 0x12, 0x58, 0x0a, 0x0d, 0x50, - 0x6f, 0x73, 0x74, 0x54, 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x74, 0x72, 0x79, 0x12, 0x22, 0x2e, 0x63, - 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x74, 0x61, 0x69, 0x6c, 0x6e, 0x65, 0x74, 0x2e, 0x76, 0x32, 0x2e, - 0x54, 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x74, 0x72, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x1a, 0x23, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x74, 0x61, 0x69, 0x6c, 0x6e, 0x65, 0x74, - 0x2e, 0x76, 0x32, 0x2e, 0x54, 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x74, 0x72, 0x79, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x56, 0x0a, 0x0e, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x44, - 0x45, 0x52, 0x50, 0x4d, 0x61, 0x70, 0x73, 0x12, 0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, - 0x74, 0x61, 0x69, 0x6c, 0x6e, 0x65, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x53, 0x74, 0x72, 0x65, 0x61, - 0x6d, 0x44, 0x45, 0x52, 0x50, 0x4d, 0x61, 0x70, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x1a, 0x19, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x74, 0x61, 0x69, 0x6c, 0x6e, 0x65, 0x74, - 0x2e, 0x76, 0x32, 0x2e, 0x44, 0x45, 0x52, 0x50, 0x4d, 0x61, 0x70, 0x30, 0x01, 0x12, 0x6f, 0x0a, - 0x12, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x52, 0x65, 0x73, 0x75, 0x6d, 0x65, 0x54, 0x6f, - 0x6b, 0x65, 0x6e, 0x12, 0x2b, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x74, 0x61, 0x69, 0x6c, - 0x6e, 0x65, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x52, 0x65, - 0x73, 0x75, 0x6d, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x1a, 0x2c, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x74, 0x61, 0x69, 0x6c, 0x6e, 0x65, 0x74, - 0x2e, 0x76, 0x32, 0x2e, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x52, 0x65, 0x73, 0x75, 0x6d, - 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5b, - 0x0a, 0x0a, 0x43, 0x6f, 0x6f, 0x72, 0x64, 0x69, 0x6e, 0x61, 0x74, 0x65, 0x12, 0x23, 0x2e, 0x63, - 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x74, 0x61, 0x69, 0x6c, 0x6e, 0x65, 0x74, 0x2e, 0x76, 0x32, 0x2e, - 0x43, 0x6f, 0x6f, 0x72, 0x64, 0x69, 0x6e, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x1a, 0x24, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x74, 0x61, 0x69, 0x6c, 0x6e, 0x65, - 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x43, 0x6f, 0x6f, 0x72, 0x64, 0x69, 0x6e, 0x61, 0x74, 0x65, 0x52, - 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x28, 0x01, 0x30, 0x01, 0x12, 0x62, 0x0a, 0x10, 0x57, - 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x73, 0x12, - 0x29, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x74, 0x61, 0x69, 0x6c, 0x6e, 0x65, 0x74, 0x2e, - 0x76, 0x32, 0x2e, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x55, 0x70, 0x64, 0x61, - 0x74, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x63, 0x6f, 0x64, - 0x65, 0x72, 0x2e, 0x74, 0x61, 0x69, 0x6c, 0x6e, 0x65, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x57, 0x6f, - 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x30, 0x01, 0x42, - 0x29, 0x5a, 0x27, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x6f, - 0x64, 0x65, 0x72, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x76, 0x32, 0x2f, 0x74, 0x61, 0x69, - 0x6c, 0x6e, 0x65, 0x74, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x33, + 0x2e, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, + 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, + 0x2e, 0x74, 0x61, 0x69, 0x6c, 0x6e, 0x65, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x57, 0x6f, 0x72, 0x6b, + 0x73, 0x70, 0x61, 0x63, 0x65, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x30, 0x01, 0x42, 0x29, 0x5a, + 0x27, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x6f, 0x64, 0x65, + 0x72, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x76, 0x32, 0x2f, 0x74, 0x61, 0x69, 0x6c, 0x6e, + 0x65, 0x74, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( diff --git a/tailnet/proto/tailnet.proto b/tailnet/proto/tailnet.proto index c7d770b9072bc..55af05c08a375 100644 --- a/tailnet/proto/tailnet.proto +++ b/tailnet/proto/tailnet.proto @@ -198,7 +198,9 @@ message TelemetryRequest { message TelemetryResponse {} -message WorkspaceUpdatesRequest {} +message WorkspaceUpdatesRequest { + bytes workspace_owner_id = 1; // UUID +} message WorkspaceUpdate { repeated Workspace upserted_workspaces = 1; diff --git a/tailnet/service.go b/tailnet/service.go index 0982ff0f629b2..dd16be824e62c 100644 --- a/tailnet/service.go +++ b/tailnet/service.go @@ -40,10 +40,13 @@ func WithStreamID(ctx context.Context, streamID StreamID) context.Context { } type WorkspaceUpdatesProvider interface { - Subscribe(peerID uuid.UUID, userID uuid.UUID) (<-chan *proto.WorkspaceUpdate, error) - Unsubscribe(peerID uuid.UUID) - Stop() - OwnsAgent(userID uuid.UUID, agentID uuid.UUID) bool + io.Closer + Subscribe(ctx context.Context, userID uuid.UUID) (Subscription, error) +} + +type Subscription interface { + io.Closer + Updates() <-chan *proto.WorkspaceUpdate } type ClientServiceOptions struct { @@ -98,34 +101,37 @@ func NewClientService(options ClientServiceOptions) ( return s, nil } -func (s *ClientService) ServeClient(ctx context.Context, version string, conn net.Conn, id uuid.UUID, agent uuid.UUID) error { - major, _, err := apiversion.Parse(version) - if err != nil { - s.Logger.Warn(ctx, "serve client called with unparsable version", slog.Error(err)) - return err - } - switch major { - case 2: - auth := ClientCoordinateeAuth{AgentID: agent} - streamID := StreamID{ - Name: "client", - ID: id, - Auth: auth, - } - return s.ServeConnV2(ctx, conn, streamID) - default: - s.Logger.Warn(ctx, "serve client called with unsupported version", slog.F("version", version)) - return ErrUnsupportedVersion - } +type TunnelAuthorizer interface { + AuthorizeByID(ctx context.Context, workspaceID uuid.UUID) error } -type ServeUserClientOptions struct { - PeerID uuid.UUID - UserID uuid.UUID - UpdatesProvider WorkspaceUpdatesProvider +type ServeClientOptions struct { + Peer uuid.UUID + // Include for multi-workspace service + Auth TunnelAuthorizer + // Include for single workspace service + Agent *uuid.UUID } -func (s *ClientService) ServeUserClient(ctx context.Context, version string, conn net.Conn, opts ServeUserClientOptions) error { +func (s *ClientService) ServeClient(ctx context.Context, version string, conn net.Conn, opts ServeClientOptions) error { + var auth CoordinateeAuth + if opts.Auth != nil { + // Multi-agent service + auth = ClientUserCoordinateeAuth{ + RBACAuth: opts.Auth, + } + } else if opts.Agent != nil { + // Single-agent service + auth = ClientCoordinateeAuth{AgentID: *opts.Agent} + } else { + panic("ServeClient called with neither auth nor agent") + } + streamID := StreamID{ + Name: "client", + ID: opts.Peer, + Auth: auth, + } + major, _, err := apiversion.Parse(version) if err != nil { s.Logger.Warn(ctx, "serve client called with unparsable version", slog.Error(err)) @@ -133,15 +139,6 @@ func (s *ClientService) ServeUserClient(ctx context.Context, version string, con } switch major { case 2: - auth := ClientUserCoordinateeAuth{ - UserID: opts.UserID, - UpdatesProvider: opts.UpdatesProvider, - } - streamID := StreamID{ - Name: "client", - ID: opts.PeerID, - Auth: auth, - } return s.ServeConnV2(ctx, conn, streamID) default: s.Logger.Warn(ctx, "serve client called with unsupported version", slog.F("version", version)) @@ -245,28 +242,28 @@ func (s *DRPCService) Coordinate(stream proto.DRPCTailnet_CoordinateStream) erro return nil } -func (s *DRPCService) WorkspaceUpdates(_ *proto.WorkspaceUpdatesRequest, stream proto.DRPCTailnet_WorkspaceUpdatesStream) error { +func (s *DRPCService) WorkspaceUpdates(req *proto.WorkspaceUpdatesRequest, stream proto.DRPCTailnet_WorkspaceUpdatesStream) error { defer stream.Close() ctx := stream.Context() streamID, ok := ctx.Value(streamIDContextKey{}).(StreamID) if !ok { - _ = stream.Close() return xerrors.New("no Stream ID") } - var ( - updatesCh <-chan *proto.WorkspaceUpdate - err error - ) + ownerID, err := uuid.FromBytes(req.WorkspaceOwnerId) + if err != nil { + return xerrors.Errorf("parse workspace owner ID: %w", err) + } + + var sub Subscription switch auth := streamID.Auth.(type) { case ClientUserCoordinateeAuth: - // Stream ID is the peer ID - updatesCh, err = s.WorkspaceUpdatesProvider.Subscribe(streamID.ID, auth.UserID) + sub, err = s.WorkspaceUpdatesProvider.Subscribe(ctx, ownerID) if err != nil { err = xerrors.Errorf("subscribe to workspace updates: %w", err) } - defer s.WorkspaceUpdatesProvider.Unsubscribe(streamID.ID) + defer sub.Close() default: err = xerrors.Errorf("workspace updates not supported by auth name %T", auth) } @@ -276,7 +273,7 @@ func (s *DRPCService) WorkspaceUpdates(_ *proto.WorkspaceUpdatesRequest, stream for { select { - case updates := <-updatesCh: + case updates := <-sub.Updates(): if updates == nil { return nil } diff --git a/tailnet/service_test.go b/tailnet/service_test.go index 0f4b4795c42e9..26c15ad712914 100644 --- a/tailnet/service_test.go +++ b/tailnet/service_test.go @@ -52,7 +52,10 @@ func TestClientService_ServeClient_V2(t *testing.T) { agentID := uuid.MustParse("20000001-0000-0000-0000-000000000000") errCh := make(chan error, 1) go func() { - err := uut.ServeClient(ctx, "2.0", s, clientID, agentID) + err := uut.ServeClient(ctx, "2.0", s, tailnet.ServeClientOptions{ + Peer: clientID, + Agent: &agentID, + }) t.Logf("ServeClient returned; err=%v", err) errCh <- err }() @@ -74,7 +77,7 @@ func TestClientService_ServeClient_V2(t *testing.T) { require.NotNil(t, call) require.Equal(t, call.ID, clientID) require.Equal(t, call.Name, "client") - require.NoError(t, call.Auth.Authorize(&proto.CoordinateRequest{ + require.NoError(t, call.Auth.Authorize(ctx, &proto.CoordinateRequest{ AddTunnel: &proto.CoordinateRequest_Tunnel{Id: agentID[:]}, })) req := testutil.RequireRecvCtx(ctx, t, call.Reqs) @@ -157,7 +160,10 @@ func TestClientService_ServeClient_V1(t *testing.T) { agentID := uuid.MustParse("20000001-0000-0000-0000-000000000000") errCh := make(chan error, 1) go func() { - err := uut.ServeClient(ctx, "1.0", s, clientID, agentID) + err := uut.ServeClient(ctx, "1.0", s, tailnet.ServeClientOptions{ + Peer: clientID, + Agent: &agentID, + }) t.Logf("ServeClient returned; err=%v", err) errCh <- err }() diff --git a/tailnet/tunnel.go b/tailnet/tunnel.go index 86833bbd8f9f5..d5a84ea52833e 100644 --- a/tailnet/tunnel.go +++ b/tailnet/tunnel.go @@ -1,6 +1,7 @@ package tailnet import ( + "context" "database/sql" "net/netip" @@ -13,13 +14,13 @@ import ( var legacyWorkspaceAgentIP = netip.MustParseAddr("fd7a:115c:a1e0:49d6:b259:b7ac:b1b2:48f4") type CoordinateeAuth interface { - Authorize(req *proto.CoordinateRequest) error + Authorize(ctx context.Context, req *proto.CoordinateRequest) error } // SingleTailnetCoordinateeAuth allows all tunnels, since Coderd and wsproxy are allowed to initiate a tunnel to any agent type SingleTailnetCoordinateeAuth struct{} -func (SingleTailnetCoordinateeAuth) Authorize(*proto.CoordinateRequest) error { +func (SingleTailnetCoordinateeAuth) Authorize(context.Context, *proto.CoordinateRequest) error { return nil } @@ -28,7 +29,7 @@ type ClientCoordinateeAuth struct { AgentID uuid.UUID } -func (c ClientCoordinateeAuth) Authorize(req *proto.CoordinateRequest) error { +func (c ClientCoordinateeAuth) Authorize(_ context.Context, req *proto.CoordinateRequest) error { if tun := req.GetAddTunnel(); tun != nil { uid, err := uuid.FromBytes(tun.Id) if err != nil { @@ -65,7 +66,7 @@ type AgentCoordinateeAuth struct { ID uuid.UUID } -func (a AgentCoordinateeAuth) Authorize(req *proto.CoordinateRequest) error { +func (a AgentCoordinateeAuth) Authorize(_ context.Context, req *proto.CoordinateRequest) error { if tun := req.GetAddTunnel(); tun != nil { return xerrors.New("agents cannot open tunnels") } @@ -93,18 +94,17 @@ func (a AgentCoordinateeAuth) Authorize(req *proto.CoordinateRequest) error { } type ClientUserCoordinateeAuth struct { - UserID uuid.UUID - UpdatesProvider WorkspaceUpdatesProvider + RBACAuth TunnelAuthorizer } -func (a ClientUserCoordinateeAuth) Authorize(req *proto.CoordinateRequest) error { +func (a ClientUserCoordinateeAuth) Authorize(ctx context.Context, req *proto.CoordinateRequest) error { if tun := req.GetAddTunnel(); tun != nil { uid, err := uuid.FromBytes(tun.Id) if err != nil { return xerrors.Errorf("parse add tunnel id: %w", err) } - isOwner := a.UpdatesProvider.OwnsAgent(a.UserID, uid) - if !isOwner { + err = a.RBACAuth.AuthorizeByID(ctx, uid) + if err != nil { return xerrors.Errorf("workspace agent not found or you do not have permission: %w", sql.ErrNoRows) } } From 8cff11b16c2f7ed3c9352ac406626e2342c2836b Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Wed, 23 Oct 2024 08:50:42 +0000 Subject: [PATCH 3/7] review p2 --- coderd/database/dbfake/dbfake.go | 22 ++++ coderd/workspaceagents_test.go | 200 +++++++++++++++++++------------ coderd/workspaceupdates.go | 64 +++++----- tailnet/service.go | 4 +- tailnet/service_test.go | 140 ++++++++++++++++++++++ 5 files changed, 319 insertions(+), 111 deletions(-) diff --git a/coderd/database/dbfake/dbfake.go b/coderd/database/dbfake/dbfake.go index 3ff9f59fa138e..2c48cf1dcb218 100644 --- a/coderd/database/dbfake/dbfake.go +++ b/coderd/database/dbfake/dbfake.go @@ -105,6 +105,20 @@ func (b WorkspaceBuildBuilder) WithAgent(mutations ...func([]*sdkproto.Agent) [] Type: "aws_instance", Agents: agents, }) + if b.ps != nil { + for _, agent := range agents { + uid, err := uuid.Parse(agent.Id) + require.NoError(b.t, err) + msg, err := json.Marshal(wspubsub.WorkspaceEvent{ + Kind: wspubsub.WorkspaceEventKindAgentConnectionUpdate, + WorkspaceID: b.ws.ID, + AgentID: &uid, + }) + require.NoError(b.t, err) + err = b.ps.Publish(wspubsub.WorkspaceEventChannel(b.ws.OwnerID), msg) + require.NoError(b.t, err) + } + } return b } @@ -224,6 +238,14 @@ func (b WorkspaceBuildBuilder) Do() WorkspaceResponse { } _ = dbgen.WorkspaceBuildParameters(b.t, b.db, b.params) + if b.ws.Deleted { + err = b.db.UpdateWorkspaceDeletedByID(ownerCtx, database.UpdateWorkspaceDeletedByIDParams{ + ID: b.ws.ID, + Deleted: true, + }) + require.NoError(b.t, err) + } + if b.ps != nil { msg, err := json.Marshal(wspubsub.WorkspaceEvent{ Kind: wspubsub.WorkspaceEventKindStateChange, diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index a30dff20c611f..bc81f72fbca53 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -1,10 +1,10 @@ package coderd_test import ( - "bytes" "context" "encoding/json" "fmt" + "maps" "net" "net/http" "runtime" @@ -1937,17 +1937,21 @@ func TestOwnedWorkspacesCoordinate(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug) - firstClient, closer, _ := coderdtest.NewWithAPI(t, &coderdtest.Options{ + firstClient, closer, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ Coordinator: tailnet.NewCoordinator(logger), IncludeProvisionerDaemon: true, }) - defer closer.Close() + t.Cleanup(func() { + _ = closer.Close() + }) firstUser := coderdtest.CreateFirstUser(t, firstClient) member, memberUser := coderdtest.CreateAnotherUser(t, firstClient, firstUser.OrganizationID, rbac.RoleTemplateAdmin()) - // Create a workspace - token := uuid.NewString() - resources, _ := buildWorkspaceWithAgent(t, member, firstUser.OrganizationID, token) + // Create a workspace with an agent + dbfake.WorkspaceBuild(t, api.Database, database.Workspace{ + OrganizationID: firstUser.OrganizationID, + OwnerID: memberUser.ID, + }).WithAgent().Do() u, err := member.URL.Parse("/api/v2/tailnet") require.NoError(t, err) @@ -1990,69 +1994,40 @@ func TestOwnedWorkspacesCoordinate(t *testing.T) { // Existing agent require.Len(t, update.UpsertedAgents, 1) require.Equal(t, update.UpsertedAgents[0].WorkspaceId, wsID) - require.EqualValues(t, update.UpsertedAgents[0].Id, resources[0].Agents[0].ID) require.Len(t, update.DeletedWorkspaces, 0) require.Len(t, update.DeletedAgents, 0) // Build a second workspace - secondToken := uuid.NewString() - secondResources, secondWorkspace := buildWorkspaceWithAgent(t, member, firstUser.OrganizationID, secondToken) - - // Workspace starting - update, err = stream.Recv() - require.NoError(t, err) - require.Len(t, update.UpsertedWorkspaces, 1) - require.Equal(t, update.UpsertedWorkspaces[0].Status, tailnetproto.Workspace_STARTING) - - require.Len(t, update.DeletedWorkspaces, 0) - require.Len(t, update.DeletedAgents, 0) - require.Len(t, update.UpsertedAgents, 0) - - // Workspace running, agent created - update, err = stream.Recv() - require.NoError(t, err) - require.Len(t, update.UpsertedWorkspaces, 1) - require.Equal(t, update.UpsertedWorkspaces[0].Status, tailnetproto.Workspace_RUNNING) - wsID = update.UpsertedWorkspaces[0].Id - require.Len(t, update.UpsertedAgents, 1) - require.Equal(t, update.UpsertedAgents[0].WorkspaceId, wsID) - require.EqualValues(t, update.UpsertedAgents[0].Id, secondResources[0].Agents[0].ID) - - require.Len(t, update.DeletedWorkspaces, 0) - require.Len(t, update.DeletedAgents, 0) - - _, err = member.CreateWorkspaceBuild(ctx, secondWorkspace.ID, codersdk.CreateWorkspaceBuildRequest{ - Transition: codersdk.WorkspaceTransitionDelete, - }) - require.NoError(t, err) - - // Wait for the workspace to be deleted - deletedAgents := make([]*tailnetproto.Agent, 0) - workspaceUpdates := make([]*tailnetproto.Workspace, 0) - require.Eventually(t, func() bool { - update, err = stream.Recv() - if err != nil { - return false - } - deletedAgents = append(deletedAgents, update.DeletedAgents...) - workspaceUpdates = append(workspaceUpdates, update.UpsertedWorkspaces...) - return len(update.DeletedWorkspaces) == 1 && - bytes.Equal(update.DeletedWorkspaces[0].Id, wsID) - }, testutil.WaitMedium, testutil.IntervalSlow) - - // We should have seen an update for the agent being deleted - require.Len(t, deletedAgents, 1) - require.EqualValues(t, deletedAgents[0].Id, secondResources[0].Agents[0].ID) - - // But we may also see a 'pending' state transition before 'deleting' - deletingFound := false - for _, ws := range workspaceUpdates { - if bytes.Equal(ws.Id, wsID) && ws.Status == tailnetproto.Workspace_DELETING { - deletingFound = true - } + secondWorkspace := dbfake.WorkspaceBuild(t, api.Database, database.Workspace{ + OrganizationID: firstUser.OrganizationID, + OwnerID: memberUser.ID, + }).WithAgent().Pubsub(api.Pubsub).Do() + + // Wait for the second workspace to be running with an agent + expectedState := map[uuid.UUID]workspace{ + secondWorkspace.Workspace.ID: { + Status: tailnetproto.Workspace_RUNNING, + NumAgents: 1, + }, } - require.True(t, deletingFound) + waitForUpdates(t, ctx, stream, map[uuid.UUID]workspace{}, expectedState) + + // Wait for the workspace and agent to be deleted + secondWorkspace.Workspace.Deleted = true + dbfake.WorkspaceBuild(t, api.Database, secondWorkspace.Workspace). + Seed(database.WorkspaceBuild{ + Transition: database.WorkspaceTransitionDelete, + BuildNumber: 2, + }).Pubsub(api.Pubsub).Do() + + priorState := expectedState + waitForUpdates(t, ctx, stream, priorState, map[uuid.UUID]workspace{ + secondWorkspace.Workspace.ID: { + Status: tailnetproto.Workspace_DELETED, + NumAgents: 0, + }, + }) } func requireGetManifest(ctx context.Context, t testing.TB, aAPI agentproto.DRPCAgentClient) agentsdk.Manifest { @@ -2075,17 +2050,90 @@ func postStartup(ctx context.Context, t testing.TB, client agent.Client, startup return err } -func buildWorkspaceWithAgent(t *testing.T, client *codersdk.Client, orgID uuid.UUID, agentToken string) ([]codersdk.WorkspaceResource, codersdk.Workspace) { - version := coderdtest.CreateTemplateVersion(t, client, orgID, &echo.Responses{ - Parse: echo.ParseComplete, - ProvisionPlan: echo.PlanComplete, - ProvisionApply: echo.ProvisionApplyWithAgent(agentToken), - }) - coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, orgID, version.ID) - workspace := coderdtest.CreateWorkspace(t, client, template.ID) - coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) - _ = agenttest.New(t, client.URL, agentToken) - resources := coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).Wait() - return resources, workspace +type workspace struct { + Status tailnetproto.Workspace_Status + NumAgents int +} + +func waitForUpdates( + t *testing.T, + //nolint:revive // t takes precedence + ctx context.Context, + stream tailnetproto.DRPCTailnet_WorkspaceUpdatesClient, + currentState map[uuid.UUID]workspace, + expectedState map[uuid.UUID]workspace, +) { + t.Helper() + errCh := make(chan error, 1) + go func() { + for { + select { + case <-ctx.Done(): + errCh <- ctx.Err() + return + default: + } + update, err := stream.Recv() + if err != nil { + errCh <- err + return + } + for _, ws := range update.UpsertedWorkspaces { + id, err := uuid.FromBytes(ws.Id) + if err != nil { + errCh <- err + return + } + currentState[id] = workspace{ + Status: ws.Status, + NumAgents: currentState[id].NumAgents, + } + } + for _, ws := range update.DeletedWorkspaces { + id, err := uuid.FromBytes(ws.Id) + if err != nil { + errCh <- err + return + } + currentState[id] = workspace{ + Status: tailnetproto.Workspace_DELETED, + NumAgents: currentState[id].NumAgents, + } + } + for _, a := range update.UpsertedAgents { + id, err := uuid.FromBytes(a.WorkspaceId) + if err != nil { + errCh <- err + return + } + currentState[id] = workspace{ + Status: currentState[id].Status, + NumAgents: currentState[id].NumAgents + 1, + } + } + for _, a := range update.DeletedAgents { + id, err := uuid.FromBytes(a.WorkspaceId) + if err != nil { + errCh <- err + return + } + currentState[id] = workspace{ + Status: currentState[id].Status, + NumAgents: currentState[id].NumAgents - 1, + } + } + if maps.Equal(currentState, expectedState) { + errCh <- nil + return + } + } + }() + select { + case err := <-errCh: + if err != nil { + t.Fatal(err) + } + case <-ctx.Done(): + t.Fatal("Timeout waiting for desired state") + } } diff --git a/coderd/workspaceupdates.go b/coderd/workspaceupdates.go index ecf766aee9ad1..0fed658307b11 100644 --- a/coderd/workspaceupdates.go +++ b/coderd/workspaceupdates.go @@ -35,28 +35,22 @@ func (w ownedWorkspace) Equal(other ownedWorkspace) bool { } type sub struct { - ctx context.Context + ctx context.Context + cancelFn context.CancelFunc + mu sync.RWMutex userID uuid.UUID - tx chan<- *proto.WorkspaceUpdate - rx <-chan *proto.WorkspaceUpdate + ch chan *proto.WorkspaceUpdate prev workspacesByID db UpdateQuerier ps pubsub.Pubsub logger slog.Logger - cancelFn func() + psCancelFn func() } func (s *sub) handleEvent(ctx context.Context, event wspubsub.WorkspaceEvent, err error) { - select { - case <-ctx.Done(): - _ = s.Close() - return - default: - } - s.mu.Lock() defer s.mu.Unlock() @@ -86,13 +80,14 @@ func (s *sub) handleEvent(ctx context.Context, event wspubsub.WorkspaceEvent, er } s.prev = latest - s.tx <- out + select { + case <-s.ctx.Done(): + return + case s.ch <- out: + } } func (s *sub) start(ctx context.Context) (err error) { - s.mu.Lock() - defer s.mu.Unlock() - rows, err := s.db.GetWorkspacesAndAgentsByOwnerID(ctx, s.userID) if err != nil { return xerrors.Errorf("get workspaces and agents by owner ID: %w", err) @@ -100,7 +95,7 @@ func (s *sub) start(ctx context.Context) (err error) { latest := convertRows(rows) initUpdate, _ := produceUpdate(workspacesByID{}, latest) - s.tx <- initUpdate + s.ch <- initUpdate s.prev = latest cancel, err := s.ps.SubscribeWithErr(wspubsub.WorkspaceEventChannel(s.userID), wspubsub.HandleWorkspaceEvent(s.handleEvent)) @@ -108,24 +103,25 @@ func (s *sub) start(ctx context.Context) (err error) { return xerrors.Errorf("subscribe to workspace event channel: %w", err) } - s.cancelFn = cancel + s.psCancelFn = cancel return nil } func (s *sub) Close() error { + s.cancelFn() + s.mu.Lock() defer s.mu.Unlock() - - if s.cancelFn != nil { - s.cancelFn() + if s.psCancelFn != nil { + s.psCancelFn() } - close(s.tx) + close(s.ch) return nil } func (s *sub) Updates() <-chan *proto.WorkspaceUpdate { - return s.rx + return s.ch } var _ tailnet.Subscription = (*sub)(nil) @@ -164,15 +160,16 @@ func (u *updatesProvider) Close() error { func (u *updatesProvider) Subscribe(ctx context.Context, userID uuid.UUID) (tailnet.Subscription, error) { ch := make(chan *proto.WorkspaceUpdate, 1) + ctx, cancel := context.WithCancel(ctx) sub := &sub{ - ctx: u.ctx, - userID: userID, - tx: ch, - rx: ch, - db: u.db, - ps: u.ps, - logger: u.logger.Named(fmt.Sprintf("workspace_updates_subscriber_%s", userID)), - prev: workspacesByID{}, + ctx: u.ctx, + cancelFn: cancel, + userID: userID, + ch: ch, + db: u.db, + ps: u.ps, + logger: u.logger.Named(fmt.Sprintf("workspace_updates_subscriber_%s", userID)), + prev: workspacesByID{}, } err := sub.start(ctx) if err != nil { @@ -285,11 +282,12 @@ type tunnelAuthorizer struct { db database.Store } -func (t *tunnelAuthorizer) AuthorizeByID(ctx context.Context, workspaceID uuid.UUID) error { - ws, err := t.db.GetWorkspaceByID(ctx, workspaceID) +func (t *tunnelAuthorizer) AuthorizeByID(ctx context.Context, agentID uuid.UUID) error { + ws, err := t.db.GetWorkspaceByAgentID(ctx, agentID) if err != nil { - return xerrors.Errorf("get workspace by ID: %w", err) + return xerrors.Errorf("get workspace by agent ID: %w", err) } + // Authorizes against `ActionSSH` return t.prep.Authorize(ctx, ws.RBACObject()) } diff --git a/tailnet/service.go b/tailnet/service.go index dd16be824e62c..cf0a9cc35f99b 100644 --- a/tailnet/service.go +++ b/tailnet/service.go @@ -273,8 +273,8 @@ func (s *DRPCService) WorkspaceUpdates(req *proto.WorkspaceUpdatesRequest, strea for { select { - case updates := <-sub.Updates(): - if updates == nil { + case updates, ok := <-sub.Updates(): + if !ok { return nil } err := stream.Send(updates) diff --git a/tailnet/service_test.go b/tailnet/service_test.go index 26c15ad712914..e753f0fa8f323 100644 --- a/tailnet/service_test.go +++ b/tailnet/service_test.go @@ -1,6 +1,7 @@ package tailnet_test import ( + "context" "io" "net" "sync/atomic" @@ -219,3 +220,142 @@ func TestNetworkTelemetryBatcher(t *testing.T) { require.Equal(t, "5", string(batch[0].Id)) require.Equal(t, "6", string(batch[1].Id)) } + +func TestWorkspaceUpdates(t *testing.T) { + t.Parallel() + + fCoord := tailnettest.NewFakeCoordinator() + var coord tailnet.Coordinator = fCoord + coordPtr := atomic.Pointer[tailnet.Coordinator]{} + coordPtr.Store(&coord) + logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug) + + updatesCh := make(chan *proto.WorkspaceUpdate, 1) + updatesProvider := &fakeUpdatesProvider{ch: updatesCh} + + uut, err := tailnet.NewClientService(tailnet.ClientServiceOptions{ + Logger: logger, + CoordPtr: &coordPtr, + WorkspaceUpdatesProvider: updatesProvider, + }) + require.NoError(t, err) + + ctx := testutil.Context(t, testutil.WaitShort) + c, s := net.Pipe() + defer c.Close() + defer s.Close() + clientID := uuid.New() + errCh := make(chan error, 1) + go func() { + err := uut.ServeClient(ctx, "2.0", s, tailnet.ServeClientOptions{ + Peer: clientID, + Auth: &fakeTunnelAuth{}, + }) + t.Logf("ServeClient returned; err=%v", err) + errCh <- err + }() + + client, err := tailnet.NewDRPCClient(c, logger) + require.NoError(t, err) + + // Coordinate + stream, err := client.Coordinate(ctx) + require.NoError(t, err) + defer stream.Close() + + err = stream.Send(&proto.CoordinateRequest{ + UpdateSelf: &proto.CoordinateRequest_UpdateSelf{Node: &proto.Node{PreferredDerp: 11}}, + }) + require.NoError(t, err) + + call := testutil.RequireRecvCtx(ctx, t, fCoord.CoordinateCalls) + require.NotNil(t, call) + require.Equal(t, call.ID, clientID) + require.Equal(t, call.Name, "client") + req := testutil.RequireRecvCtx(ctx, t, call.Reqs) + require.Equal(t, int32(11), req.GetUpdateSelf().GetNode().GetPreferredDerp()) + + // Authorize uses `ClientUserCoordinateeAuth` + agentID := uuid.New() + agentID[0] = 1 + require.NoError(t, call.Auth.Authorize(ctx, &proto.CoordinateRequest{ + AddTunnel: &proto.CoordinateRequest_Tunnel{Id: tailnet.UUIDToByteSlice(agentID)}, + })) + agentID2 := uuid.New() + agentID2[0] = 2 + require.Error(t, call.Auth.Authorize(ctx, &proto.CoordinateRequest{ + AddTunnel: &proto.CoordinateRequest_Tunnel{Id: tailnet.UUIDToByteSlice(agentID2)}, + })) + + // Workspace updates + expected := &proto.WorkspaceUpdate{ + UpsertedWorkspaces: []*proto.Workspace{ + { + Id: tailnet.UUIDToByteSlice(uuid.New()), + Name: "ws1", + Status: proto.Workspace_RUNNING, + }, + }, + UpsertedAgents: []*proto.Agent{}, + DeletedWorkspaces: []*proto.Workspace{}, + DeletedAgents: []*proto.Agent{}, + } + updatesCh <- expected + + updatesStream, err := client.WorkspaceUpdates(ctx, &proto.WorkspaceUpdatesRequest{ + WorkspaceOwnerId: tailnet.UUIDToByteSlice(clientID), + }) + require.NoError(t, err) + defer updatesStream.Close() + + updates, err := updatesStream.Recv() + require.NoError(t, err) + require.Len(t, updates.GetUpsertedWorkspaces(), 1) + require.Equal(t, expected.GetUpsertedWorkspaces()[0].GetName(), updates.GetUpsertedWorkspaces()[0].GetName()) + require.Equal(t, expected.GetUpsertedWorkspaces()[0].GetStatus(), updates.GetUpsertedWorkspaces()[0].GetStatus()) + require.Equal(t, expected.GetUpsertedWorkspaces()[0].GetId(), updates.GetUpsertedWorkspaces()[0].GetId()) + + err = c.Close() + require.NoError(t, err) + err = testutil.RequireRecvCtx(ctx, t, errCh) + require.True(t, xerrors.Is(err, io.EOF) || xerrors.Is(err, io.ErrClosedPipe)) +} + +type fakeUpdatesProvider struct { + ch chan *proto.WorkspaceUpdate +} + +func (*fakeUpdatesProvider) Close() error { + return nil +} + +func (f *fakeUpdatesProvider) Subscribe(context.Context, uuid.UUID) (tailnet.Subscription, error) { + return &fakeSubscription{ch: f.ch}, nil +} + +type fakeSubscription struct { + ch chan *proto.WorkspaceUpdate +} + +func (*fakeSubscription) Close() error { + return nil +} + +func (f *fakeSubscription) Updates() <-chan *proto.WorkspaceUpdate { + return f.ch +} + +var _ tailnet.Subscription = (*fakeSubscription)(nil) + +var _ tailnet.WorkspaceUpdatesProvider = (*fakeUpdatesProvider)(nil) + +type fakeTunnelAuth struct{} + +func (*fakeTunnelAuth) AuthorizeByID(_ context.Context, workspaceID uuid.UUID) error { + if workspaceID[0] != 1 { + return xerrors.New("policy disallows request") + } + return nil +} + +var _ tailnet.TunnelAuthorizer = (*fakeTunnelAuth)(nil) From eafee6b5b00fa13fc5a792db6f2bc5abfa3f8520 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Wed, 23 Oct 2024 08:54:41 +0000 Subject: [PATCH 4/7] remove mutex on sub close --- coderd/workspaceupdates.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/coderd/workspaceupdates.go b/coderd/workspaceupdates.go index 0fed658307b11..111436aba3d6d 100644 --- a/coderd/workspaceupdates.go +++ b/coderd/workspaceupdates.go @@ -110,8 +110,6 @@ func (s *sub) start(ctx context.Context) (err error) { func (s *sub) Close() error { s.cancelFn() - s.mu.Lock() - defer s.mu.Unlock() if s.psCancelFn != nil { s.psCancelFn() } From 8195b935e9c69dbacc5e5d26f13afacd0d2e7242 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Wed, 23 Oct 2024 09:07:41 +0000 Subject: [PATCH 5/7] remove sql no rows --- tailnet/tunnel.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tailnet/tunnel.go b/tailnet/tunnel.go index d5a84ea52833e..08dfab7f7788c 100644 --- a/tailnet/tunnel.go +++ b/tailnet/tunnel.go @@ -2,7 +2,6 @@ package tailnet import ( "context" - "database/sql" "net/netip" "github.com/google/uuid" @@ -105,7 +104,7 @@ func (a ClientUserCoordinateeAuth) Authorize(ctx context.Context, req *proto.Coo } err = a.RBACAuth.AuthorizeByID(ctx, uid) if err != nil { - return xerrors.Errorf("workspace agent not found or you do not have permission: %w", sql.ErrNoRows) + return xerrors.Errorf("workspace agent not found or you do not have permission") } } From 4ef174aea4d7f94cc3e8f038a4d768c92bd25648 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Wed, 23 Oct 2024 13:52:29 +0000 Subject: [PATCH 6/7] rbac filter --- cli/server.go | 2 +- coderd/apidoc/docs.go | 4 +- coderd/apidoc/swagger.json | 4 +- coderd/coderd.go | 2 +- coderd/coderdtest/coderdtest.go | 9 +++- coderd/database/dbfake/dbfake.go | 14 ------- coderd/workspaceagents.go | 35 +++++++++------- coderd/workspaceagents_test.go | 60 ++++++++++++++------------- coderd/workspaceupdates.go | 71 +++++++++++++++++++++----------- coderd/workspaceupdates_test.go | 48 +++++++++++++++++---- docs/reference/api/agents.md | 2 +- tailnet/coordinator_test.go | 18 +++++--- tailnet/service.go | 38 +++-------------- tailnet/service_test.go | 32 +++++++++----- tailnet/tunnel.go | 28 ++++--------- 15 files changed, 198 insertions(+), 169 deletions(-) diff --git a/cli/server.go b/cli/server.go index 2e53974aca20b..2154418eedf39 100644 --- a/cli/server.go +++ b/cli/server.go @@ -728,7 +728,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. options.Database = dbmetrics.NewDBMetrics(options.Database, options.Logger, options.PrometheusRegistry) } - wsUpdates, err := coderd.NewUpdatesProvider(logger.Named("workspace_updates"), options.Database, options.Pubsub) + wsUpdates, err := coderd.NewUpdatesProvider(logger.Named("workspace_updates"), options.Pubsub, options.Database, options.Authorizer) if err != nil { return xerrors.Errorf("create workspace updates provider: %w", err) } diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index a2a6ae2fcd2aa..48b550c9ed010 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -3780,8 +3780,8 @@ const docTemplate = `{ "tags": [ "Agents" ], - "summary": "User-scoped agent coordination", - "operationId": "user-scoped-agent-coordination", + "summary": "User-scoped tailnet RPC connection", + "operationId": "user-scoped-tailnet-rpc-connection", "responses": { "101": { "description": "Switching Protocols" diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index acd036bf4e2b0..c9c79b443d3d0 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -3324,8 +3324,8 @@ } ], "tags": ["Agents"], - "summary": "User-scoped agent coordination", - "operationId": "user-scoped-agent-coordination", + "summary": "User-scoped tailnet RPC connection", + "operationId": "user-scoped-tailnet-rpc-connection", "responses": { "101": { "description": "Switching Protocols" diff --git a/coderd/coderd.go b/coderd/coderd.go index e3f0cd4d8fe11..ded06918cceda 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1332,7 +1332,7 @@ func New(options *Options) *API { }) r.Route("/tailnet", func(r chi.Router) { r.Use(apiKeyMiddleware) - r.Get("/", api.tailnet) + r.Get("/", api.tailnetRPCConn) }) }) diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 0f33628c50b25..775e8764b622e 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -159,10 +159,10 @@ type Options struct { DatabaseRolluper *dbrollup.Rolluper WorkspaceUsageTrackerFlush chan int WorkspaceUsageTrackerTick chan time.Time + NotificationsEnqueuer notifications.Enqueuer APIKeyEncryptionCache cryptokeys.EncryptionKeycache OIDCConvertKeyCache cryptokeys.SigningKeycache Clock quartz.Clock - NotificationsEnqueuer notifications.Enqueuer WorkspaceUpdatesProvider tailnet.WorkspaceUpdatesProvider } @@ -258,7 +258,12 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can if options.WorkspaceUpdatesProvider == nil { var err error - options.WorkspaceUpdatesProvider, err = coderd.NewUpdatesProvider(options.Logger.Named("workspace_updates"), options.Database, options.Pubsub) + options.WorkspaceUpdatesProvider, err = coderd.NewUpdatesProvider( + options.Logger.Named("workspace_updates"), + options.Pubsub, + options.Database, + options.Authorizer, + ) require.NoError(t, err) t.Cleanup(func() { _ = options.WorkspaceUpdatesProvider.Close() diff --git a/coderd/database/dbfake/dbfake.go b/coderd/database/dbfake/dbfake.go index 2c48cf1dcb218..ca514479cab6a 100644 --- a/coderd/database/dbfake/dbfake.go +++ b/coderd/database/dbfake/dbfake.go @@ -105,20 +105,6 @@ func (b WorkspaceBuildBuilder) WithAgent(mutations ...func([]*sdkproto.Agent) [] Type: "aws_instance", Agents: agents, }) - if b.ps != nil { - for _, agent := range agents { - uid, err := uuid.Parse(agent.Id) - require.NoError(b.t, err) - msg, err := json.Marshal(wspubsub.WorkspaceEvent{ - Kind: wspubsub.WorkspaceEventKindAgentConnectionUpdate, - WorkspaceID: b.ws.ID, - AgentID: &uid, - }) - require.NoError(b.t, err) - err = b.ps.Publish(wspubsub.WorkspaceEventChannel(b.ws.OwnerID), msg) - require.NoError(b.t, err) - } - } return b } diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index b7b58b1ab5b56..922d80f0e8085 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -871,9 +871,12 @@ func (api *API) workspaceAgentClientCoordinate(rw http.ResponseWriter, r *http.R go httpapi.Heartbeat(ctx, conn) defer conn.Close(websocket.StatusNormalClosure, "") - err = api.TailnetClientService.ServeClient(ctx, version, wsNetConn, tailnet.ServeClientOptions{ - Peer: peerID, - Agent: &workspaceAgent.ID, + err = api.TailnetClientService.ServeClient(ctx, version, wsNetConn, tailnet.StreamID{ + Name: "client", + ID: peerID, + Auth: tailnet.ClientCoordinateeAuth{ + AgentID: workspaceAgent.ID, + }, }) if err != nil && !xerrors.Is(err, io.EOF) && !xerrors.Is(err, context.Canceled) { _ = conn.Close(websocket.StatusInternalError, err.Error()) @@ -891,6 +894,7 @@ func (api *API) handleResumeToken(ctx context.Context, rw http.ResponseWriter, r // case we just want to generate a new peer ID. if xerrors.Is(err, jwtutils.ErrMissingKeyID) { peerID = uuid.New() + err = nil } else if err != nil { httpapi.Write(ctx, rw, http.StatusUnauthorized, codersdk.Response{ Message: workspacesdk.CoordinateAPIInvalidResumeToken, @@ -899,7 +903,7 @@ func (api *API) handleResumeToken(ctx context.Context, rw http.ResponseWriter, r {Field: "resume_token", Detail: workspacesdk.CoordinateAPIInvalidResumeToken}, }, }) - return + return peerID, err } else { api.Logger.Debug(ctx, "accepted coordinate resume token for peer", slog.F("peer_id", peerID.String())) @@ -1479,13 +1483,13 @@ func (api *API) workspaceAgentsExternalAuthListen(ctx context.Context, rw http.R } } -// @Summary User-scoped agent coordination -// @ID user-scoped-agent-coordination +// @Summary User-scoped tailnet RPC connection +// @ID user-scoped-tailnet-rpc-connection // @Security CoderSessionToken // @Tags Agents // @Success 101 // @Router /tailnet [get] -func (api *API) tailnet(rw http.ResponseWriter, r *http.Request) { +func (api *API) tailnetRPCConn(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() version := "2.0" @@ -1509,8 +1513,8 @@ func (api *API) tailnet(rw http.ResponseWriter, r *http.Request) { return } - // Used to authorize tunnel requests, and filter workspace update DB queries - prepared, err := api.HTTPAuth.AuthorizeSQLFilter(r, policy.ActionRead, rbac.ResourceWorkspace.Type) + // Used to authorize tunnel request + sshPrep, err := api.HTTPAuth.AuthorizeSQLFilter(r, policy.ActionSSH, rbac.ResourceWorkspace.Type) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error preparing sql filter.", @@ -1537,11 +1541,14 @@ func (api *API) tailnet(rw http.ResponseWriter, r *http.Request) { defer conn.Close(websocket.StatusNormalClosure, "") go httpapi.Heartbeat(ctx, conn) - err = api.TailnetClientService.ServeClient(ctx, version, wsNetConn, tailnet.ServeClientOptions{ - Peer: peerID, - Auth: &tunnelAuthorizer{ - prep: prepared, - db: api.Database, + err = api.TailnetClientService.ServeClient(ctx, version, wsNetConn, tailnet.StreamID{ + Name: "client", + ID: peerID, + Auth: tailnet.ClientUserCoordinateeAuth{ + Auth: &rbacAuthorizer{ + sshPrep: sshPrep, + db: api.Database, + }, }, }) if err != nil && !xerrors.Is(err, io.EOF) && !xerrors.Is(err, context.Canceled) { diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index bc81f72fbca53..1ab2eb64b874a 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -1937,21 +1937,14 @@ func TestOwnedWorkspacesCoordinate(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug) - firstClient, closer, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ - Coordinator: tailnet.NewCoordinator(logger), - IncludeProvisionerDaemon: true, - }) - t.Cleanup(func() { - _ = closer.Close() + firstClient, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ + Coordinator: tailnet.NewCoordinator(logger), }) firstUser := coderdtest.CreateFirstUser(t, firstClient) member, memberUser := coderdtest.CreateAnotherUser(t, firstClient, firstUser.OrganizationID, rbac.RoleTemplateAdmin()) // Create a workspace with an agent - dbfake.WorkspaceBuild(t, api.Database, database.Workspace{ - OrganizationID: firstUser.OrganizationID, - OwnerID: memberUser.ID, - }).WithAgent().Do() + firstWorkspace := buildWorkspaceWithAgent(t, member, firstUser.OrganizationID, memberUser.ID, api.Database, api.Pubsub) u, err := member.URL.Parse("/api/v2/tailnet") require.NoError(t, err) @@ -1984,29 +1977,22 @@ func TestOwnedWorkspacesCoordinate(t *testing.T) { }) require.NoError(t, err) - // Existing workspace + // First update will contain the existing workspace and agent update, err := stream.Recv() require.NoError(t, err) require.Len(t, update.UpsertedWorkspaces, 1) - require.Equal(t, update.UpsertedWorkspaces[0].Status, tailnetproto.Workspace_RUNNING) - wsID := update.UpsertedWorkspaces[0].Id - - // Existing agent + require.EqualValues(t, update.UpsertedWorkspaces[0].Id, firstWorkspace.ID) require.Len(t, update.UpsertedAgents, 1) - require.Equal(t, update.UpsertedAgents[0].WorkspaceId, wsID) - + require.EqualValues(t, update.UpsertedAgents[0].WorkspaceId, firstWorkspace.ID) require.Len(t, update.DeletedWorkspaces, 0) require.Len(t, update.DeletedAgents, 0) // Build a second workspace - secondWorkspace := dbfake.WorkspaceBuild(t, api.Database, database.Workspace{ - OrganizationID: firstUser.OrganizationID, - OwnerID: memberUser.ID, - }).WithAgent().Pubsub(api.Pubsub).Do() + secondWorkspace := buildWorkspaceWithAgent(t, member, firstUser.OrganizationID, memberUser.ID, api.Database, api.Pubsub) // Wait for the second workspace to be running with an agent expectedState := map[uuid.UUID]workspace{ - secondWorkspace.Workspace.ID: { + secondWorkspace.ID: { Status: tailnetproto.Workspace_RUNNING, NumAgents: 1, }, @@ -2014,22 +2000,38 @@ func TestOwnedWorkspacesCoordinate(t *testing.T) { waitForUpdates(t, ctx, stream, map[uuid.UUID]workspace{}, expectedState) // Wait for the workspace and agent to be deleted - secondWorkspace.Workspace.Deleted = true - dbfake.WorkspaceBuild(t, api.Database, secondWorkspace.Workspace). + secondWorkspace.Deleted = true + dbfake.WorkspaceBuild(t, api.Database, secondWorkspace). Seed(database.WorkspaceBuild{ Transition: database.WorkspaceTransitionDelete, BuildNumber: 2, - }).Pubsub(api.Pubsub).Do() + }).Do() - priorState := expectedState - waitForUpdates(t, ctx, stream, priorState, map[uuid.UUID]workspace{ - secondWorkspace.Workspace.ID: { + waitForUpdates(t, ctx, stream, expectedState, map[uuid.UUID]workspace{ + secondWorkspace.ID: { Status: tailnetproto.Workspace_DELETED, NumAgents: 0, }, }) } +func buildWorkspaceWithAgent( + t *testing.T, + client *codersdk.Client, + orgID uuid.UUID, + ownerID uuid.UUID, + db database.Store, + ps pubsub.Pubsub, +) database.WorkspaceTable { + r := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OrganizationID: orgID, + OwnerID: ownerID, + }).WithAgent().Pubsub(ps).Do() + _ = agenttest.New(t, client.URL, r.AgentToken) + coderdtest.NewWorkspaceAgentWaiter(t, client, r.Workspace.ID).Wait() + return r.Workspace +} + func requireGetManifest(ctx context.Context, t testing.TB, aAPI agentproto.DRPCAgentClient) agentsdk.Manifest { mp, err := aAPI.GetManifest(ctx, &agentproto.GetManifestRequest{}) require.NoError(t, err) @@ -2134,6 +2136,6 @@ func waitForUpdates( t.Fatal(err) } case <-ctx.Done(): - t.Fatal("Timeout waiting for desired state") + t.Fatal("Timeout waiting for desired state", currentState) } } diff --git a/coderd/workspaceupdates.go b/coderd/workspaceupdates.go index 111436aba3d6d..eca63d23964d8 100644 --- a/coderd/workspaceupdates.go +++ b/coderd/workspaceupdates.go @@ -11,8 +11,10 @@ import ( "cdr.dev/slog" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/pubsub" "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/coderd/wspubsub" "github.com/coder/coder/v2/codersdk" @@ -20,6 +22,11 @@ import ( "github.com/coder/coder/v2/tailnet/proto" ) +type UpdatesQuerier interface { + GetAuthorizedWorkspacesAndAgentsByOwnerID(ctx context.Context, ownerID uuid.UUID, prep rbac.PreparedAuthorized) ([]database.GetWorkspacesAndAgentsByOwnerIDRow, error) + GetWorkspaceByAgentID(ctx context.Context, agentID uuid.UUID) (database.Workspace, error) +} + type workspacesByID = map[uuid.UUID]ownedWorkspace type ownedWorkspace struct { @@ -38,12 +45,13 @@ type sub struct { ctx context.Context cancelFn context.CancelFunc - mu sync.RWMutex - userID uuid.UUID - ch chan *proto.WorkspaceUpdate - prev workspacesByID + mu sync.RWMutex + userID uuid.UUID + ch chan *proto.WorkspaceUpdate + prev workspacesByID + readPrep rbac.PreparedAuthorized - db UpdateQuerier + db UpdatesQuerier ps pubsub.Pubsub logger slog.Logger @@ -68,11 +76,12 @@ func (s *sub) handleEvent(ctx context.Context, event wspubsub.WorkspaceEvent, er } } - row, err := s.db.GetWorkspacesAndAgentsByOwnerID(ctx, s.userID) + rows, err := s.db.GetAuthorizedWorkspacesAndAgentsByOwnerID(ctx, s.userID, s.readPrep) if err != nil { s.logger.Warn(ctx, "failed to get workspaces and agents by owner ID", slog.Error(err)) + return } - latest := convertRows(row) + latest := convertRows(rows) out, updated := produceUpdate(s.prev, latest) if !updated { @@ -88,7 +97,7 @@ func (s *sub) handleEvent(ctx context.Context, event wspubsub.WorkspaceEvent, er } func (s *sub) start(ctx context.Context) (err error) { - rows, err := s.db.GetWorkspacesAndAgentsByOwnerID(ctx, s.userID) + rows, err := s.db.GetAuthorizedWorkspacesAndAgentsByOwnerID(ctx, s.userID, s.readPrep) if err != nil { return xerrors.Errorf("get workspaces and agents by owner ID: %w", err) } @@ -124,14 +133,11 @@ func (s *sub) Updates() <-chan *proto.WorkspaceUpdate { var _ tailnet.Subscription = (*sub)(nil) -type UpdateQuerier interface { - GetWorkspacesAndAgentsByOwnerID(ctx context.Context, ownerID uuid.UUID) ([]database.GetWorkspacesAndAgentsByOwnerIDRow, error) -} - type updatesProvider struct { - db UpdateQuerier ps pubsub.Pubsub logger slog.Logger + db UpdatesQuerier + auth rbac.Authorizer ctx context.Context cancelFn func() @@ -139,14 +145,20 @@ type updatesProvider struct { var _ tailnet.WorkspaceUpdatesProvider = (*updatesProvider)(nil) -func NewUpdatesProvider(logger slog.Logger, db UpdateQuerier, ps pubsub.Pubsub) (tailnet.WorkspaceUpdatesProvider, error) { +func NewUpdatesProvider( + logger slog.Logger, + ps pubsub.Pubsub, + db UpdatesQuerier, + auth rbac.Authorizer, +) (tailnet.WorkspaceUpdatesProvider, error) { ctx, cancel := context.WithCancel(context.Background()) out := &updatesProvider{ - ctx: ctx, - cancelFn: cancel, + auth: auth, db: db, ps: ps, logger: logger, + ctx: ctx, + cancelFn: cancel, } return out, nil } @@ -157,10 +169,18 @@ func (u *updatesProvider) Close() error { } func (u *updatesProvider) Subscribe(ctx context.Context, userID uuid.UUID) (tailnet.Subscription, error) { + actor, ok := dbauthz.ActorFromContext(ctx) + if !ok { + return nil, xerrors.Errorf("actor not found in context") + } + readPrep, err := u.auth.Prepare(ctx, actor, policy.ActionRead, rbac.ResourceWorkspace.Type) + if err != nil { + return nil, xerrors.Errorf("prepare read action: %w", err) + } ch := make(chan *proto.WorkspaceUpdate, 1) ctx, cancel := context.WithCancel(ctx) sub := &sub{ - ctx: u.ctx, + ctx: ctx, cancelFn: cancel, userID: userID, ch: ch, @@ -168,8 +188,9 @@ func (u *updatesProvider) Subscribe(ctx context.Context, userID uuid.UUID) (tail ps: u.ps, logger: u.logger.Named(fmt.Sprintf("workspace_updates_subscriber_%s", userID)), prev: workspacesByID{}, + readPrep: readPrep, } - err := sub.start(ctx) + err = sub.start(ctx) if err != nil { _ = sub.Close() return nil, err @@ -275,18 +296,18 @@ func convertRows(rows []database.GetWorkspacesAndAgentsByOwnerIDRow) workspacesB return out } -type tunnelAuthorizer struct { - prep rbac.PreparedAuthorized - db database.Store +type rbacAuthorizer struct { + sshPrep rbac.PreparedAuthorized + db UpdatesQuerier } -func (t *tunnelAuthorizer) AuthorizeByID(ctx context.Context, agentID uuid.UUID) error { - ws, err := t.db.GetWorkspaceByAgentID(ctx, agentID) +func (r *rbacAuthorizer) AuthorizeTunnel(ctx context.Context, agentID uuid.UUID) error { + ws, err := r.db.GetWorkspaceByAgentID(ctx, agentID) if err != nil { return xerrors.Errorf("get workspace by agent ID: %w", err) } // Authorizes against `ActionSSH` - return t.prep.Authorize(ctx, ws.RBACObject()) + return r.sshPrep.Authorize(ctx, ws.RBACObject()) } -var _ tailnet.TunnelAuthorizer = (*tunnelAuthorizer)(nil) +var _ tailnet.TunnelAuthorizer = (*rbacAuthorizer)(nil) diff --git a/coderd/workspaceupdates_test.go b/coderd/workspaceupdates_test.go index 8f2b97f81bf8b..3e6bd8f03719c 100644 --- a/coderd/workspaceupdates_test.go +++ b/coderd/workspaceupdates_test.go @@ -13,7 +13,10 @@ import ( "cdr.dev/slog/sloggers/slogtest" "github.com/coder/coder/v2/coderd" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/pubsub" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/wspubsub" "github.com/coder/coder/v2/tailnet" "github.com/coder/coder/v2/tailnet/proto" @@ -32,12 +35,21 @@ func TestWorkspaceUpdates(t *testing.T) { ws2IDSlice := tailnet.UUIDToByteSlice(ws2ID) ws3ID := uuid.New() ws3IDSlice := tailnet.UUIDToByteSlice(ws3ID) - ownerID := uuid.New() agent2ID := uuid.New() agent2IDSlice := tailnet.UUIDToByteSlice(agent2ID) ws4ID := uuid.New() ws4IDSlice := tailnet.UUIDToByteSlice(ws4ID) + ownerID := uuid.New() + memberRole, err := rbac.RoleByName(rbac.RoleMember()) + require.NoError(t, err) + ownerSubject := rbac.Subject{ + FriendlyName: "member", + ID: ownerID.String(), + Roles: rbac.Roles{memberRole}, + Scope: rbac.ScopeAll, + } + t.Run("Basic", func(t *testing.T) { t.Parallel() @@ -77,13 +89,13 @@ func TestWorkspaceUpdates(t *testing.T) { cbs: map[string]pubsub.ListenerWithErr{}, } - updateProvider, err := coderd.NewUpdatesProvider(slogtest.Make(t, nil), db, ps) + updateProvider, err := coderd.NewUpdatesProvider(slogtest.Make(t, nil), ps, db, &mockAuthorizer{}) require.NoError(t, err) t.Cleanup(func() { _ = updateProvider.Close() }) - sub, err := updateProvider.Subscribe(ctx, ownerID) + sub, err := updateProvider.Subscribe(dbauthz.As(ctx, ownerSubject), ownerID) require.NoError(t, err) ch := sub.Updates() @@ -219,13 +231,13 @@ func TestWorkspaceUpdates(t *testing.T) { cbs: map[string]pubsub.ListenerWithErr{}, } - updateProvider, err := coderd.NewUpdatesProvider(slogtest.Make(t, nil), db, ps) + updateProvider, err := coderd.NewUpdatesProvider(slogtest.Make(t, nil), ps, db, &mockAuthorizer{}) require.NoError(t, err) t.Cleanup(func() { _ = updateProvider.Close() }) - sub, err := updateProvider.Subscribe(ctx, ownerID) + sub, err := updateProvider.Subscribe(dbauthz.As(ctx, ownerSubject), ownerID) require.NoError(t, err) ch := sub.Updates() @@ -255,7 +267,7 @@ func TestWorkspaceUpdates(t *testing.T) { require.Equal(t, expected, update) require.NoError(t, err) - sub, err = updateProvider.Subscribe(ctx, ownerID) + sub, err = updateProvider.Subscribe(dbauthz.As(ctx, ownerSubject), ownerID) require.NoError(t, err) ch = sub.Updates() @@ -277,12 +289,17 @@ type mockWorkspaceStore struct { orderedRows []database.GetWorkspacesAndAgentsByOwnerIDRow } -// GetWorkspacesAndAgents implements tailnet.UpdateQuerier. -func (m *mockWorkspaceStore) GetWorkspacesAndAgentsByOwnerID(context.Context, uuid.UUID) ([]database.GetWorkspacesAndAgentsByOwnerIDRow, error) { +// GetAuthorizedWorkspacesAndAgentsByOwnerID implements coderd.UpdatesQuerier. +func (m *mockWorkspaceStore) GetAuthorizedWorkspacesAndAgentsByOwnerID(context.Context, uuid.UUID, rbac.PreparedAuthorized) ([]database.GetWorkspacesAndAgentsByOwnerIDRow, error) { return m.orderedRows, nil } -var _ coderd.UpdateQuerier = (*mockWorkspaceStore)(nil) +// GetWorkspaceByAgentID implements coderd.UpdatesQuerier. +func (*mockWorkspaceStore) GetWorkspaceByAgentID(context.Context, uuid.UUID) (database.Workspace, error) { + return database.Workspace{}, nil +} + +var _ coderd.UpdatesQuerier = (*mockWorkspaceStore)(nil) type mockPubsub struct { cbs map[string]pubsub.ListenerWithErr @@ -313,3 +330,16 @@ func (m *mockPubsub) SubscribeWithErr(event string, listener pubsub.ListenerWith } var _ pubsub.Pubsub = (*mockPubsub)(nil) + +type mockAuthorizer struct{} + +func (*mockAuthorizer) Authorize(context.Context, rbac.Subject, policy.Action, rbac.Object) error { + return nil +} + +// Prepare implements rbac.Authorizer. +func (*mockAuthorizer) Prepare(context.Context, rbac.Subject, policy.Action, string) (rbac.PreparedAuthorized, error) { + return nil, nil +} + +var _ rbac.Authorizer = (*mockAuthorizer)(nil) diff --git a/docs/reference/api/agents.md b/docs/reference/api/agents.md index 44cb0fa154ae5..6ccffeb82305d 100644 --- a/docs/reference/api/agents.md +++ b/docs/reference/api/agents.md @@ -20,7 +20,7 @@ curl -X GET http://coder-server:8080/api/v2/derp-map \ To perform this operation, you must be authenticated. [Learn more](authentication.md). -## User-scoped agent coordination +## User-scoped tailnet RPC connection ### Code samples diff --git a/tailnet/coordinator_test.go b/tailnet/coordinator_test.go index 5d4ec51008920..918b9c7b0533c 100644 --- a/tailnet/coordinator_test.go +++ b/tailnet/coordinator_test.go @@ -328,9 +328,12 @@ func TestRemoteCoordination(t *testing.T) { serveErr := make(chan error, 1) go func() { - err := svc.ServeClient(ctx, proto.CurrentVersion.String(), sC, tailnet.ServeClientOptions{ - Peer: clientID, - Agent: &agentID, + err := svc.ServeClient(ctx, proto.CurrentVersion.String(), sC, tailnet.StreamID{ + Name: "client", + ID: clientID, + Auth: tailnet.ClientCoordinateeAuth{ + AgentID: agentID, + }, }) serveErr <- err }() @@ -380,9 +383,12 @@ func TestRemoteCoordination_SendsReadyForHandshake(t *testing.T) { serveErr := make(chan error, 1) go func() { - err := svc.ServeClient(ctx, proto.CurrentVersion.String(), sC, tailnet.ServeClientOptions{ - Peer: clientID, - Agent: &agentID, + err := svc.ServeClient(ctx, proto.CurrentVersion.String(), sC, tailnet.StreamID{ + Name: "client", + ID: clientID, + Auth: tailnet.ClientCoordinateeAuth{ + AgentID: agentID, + }, }) serveErr <- err }() diff --git a/tailnet/service.go b/tailnet/service.go index cf0a9cc35f99b..35c9cf2607d5d 100644 --- a/tailnet/service.go +++ b/tailnet/service.go @@ -49,6 +49,10 @@ type Subscription interface { Updates() <-chan *proto.WorkspaceUpdate } +type TunnelAuthorizer interface { + AuthorizeTunnel(ctx context.Context, agentID uuid.UUID) error +} + type ClientServiceOptions struct { Logger slog.Logger CoordPtr *atomic.Pointer[Coordinator] @@ -101,37 +105,7 @@ func NewClientService(options ClientServiceOptions) ( return s, nil } -type TunnelAuthorizer interface { - AuthorizeByID(ctx context.Context, workspaceID uuid.UUID) error -} - -type ServeClientOptions struct { - Peer uuid.UUID - // Include for multi-workspace service - Auth TunnelAuthorizer - // Include for single workspace service - Agent *uuid.UUID -} - -func (s *ClientService) ServeClient(ctx context.Context, version string, conn net.Conn, opts ServeClientOptions) error { - var auth CoordinateeAuth - if opts.Auth != nil { - // Multi-agent service - auth = ClientUserCoordinateeAuth{ - RBACAuth: opts.Auth, - } - } else if opts.Agent != nil { - // Single-agent service - auth = ClientCoordinateeAuth{AgentID: *opts.Agent} - } else { - panic("ServeClient called with neither auth nor agent") - } - streamID := StreamID{ - Name: "client", - ID: opts.Peer, - Auth: auth, - } - +func (s *ClientService) ServeClient(ctx context.Context, version string, conn net.Conn, streamID StreamID) error { major, _, err := apiversion.Parse(version) if err != nil { s.Logger.Warn(ctx, "serve client called with unparsable version", slog.Error(err)) @@ -263,13 +237,13 @@ func (s *DRPCService) WorkspaceUpdates(req *proto.WorkspaceUpdatesRequest, strea if err != nil { err = xerrors.Errorf("subscribe to workspace updates: %w", err) } - defer sub.Close() default: err = xerrors.Errorf("workspace updates not supported by auth name %T", auth) } if err != nil { return err } + defer sub.Close() for { select { diff --git a/tailnet/service_test.go b/tailnet/service_test.go index e753f0fa8f323..50275301f6f02 100644 --- a/tailnet/service_test.go +++ b/tailnet/service_test.go @@ -53,9 +53,12 @@ func TestClientService_ServeClient_V2(t *testing.T) { agentID := uuid.MustParse("20000001-0000-0000-0000-000000000000") errCh := make(chan error, 1) go func() { - err := uut.ServeClient(ctx, "2.0", s, tailnet.ServeClientOptions{ - Peer: clientID, - Agent: &agentID, + err := uut.ServeClient(ctx, "2.0", s, tailnet.StreamID{ + Name: "client", + ID: clientID, + Auth: tailnet.ClientCoordinateeAuth{ + AgentID: agentID, + }, }) t.Logf("ServeClient returned; err=%v", err) errCh <- err @@ -161,9 +164,12 @@ func TestClientService_ServeClient_V1(t *testing.T) { agentID := uuid.MustParse("20000001-0000-0000-0000-000000000000") errCh := make(chan error, 1) go func() { - err := uut.ServeClient(ctx, "1.0", s, tailnet.ServeClientOptions{ - Peer: clientID, - Agent: &agentID, + err := uut.ServeClient(ctx, "1.0", s, tailnet.StreamID{ + Name: "client", + ID: clientID, + Auth: tailnet.ClientCoordinateeAuth{ + AgentID: agentID, + }, }) t.Logf("ServeClient returned; err=%v", err) errCh <- err @@ -247,9 +253,12 @@ func TestWorkspaceUpdates(t *testing.T) { clientID := uuid.New() errCh := make(chan error, 1) go func() { - err := uut.ServeClient(ctx, "2.0", s, tailnet.ServeClientOptions{ - Peer: clientID, - Auth: &fakeTunnelAuth{}, + err := uut.ServeClient(ctx, "2.0", s, tailnet.StreamID{ + Name: "client", + ID: clientID, + Auth: tailnet.ClientUserCoordinateeAuth{ + Auth: &fakeTunnelAuth{}, + }, }) t.Logf("ServeClient returned; err=%v", err) errCh <- err @@ -351,8 +360,9 @@ var _ tailnet.WorkspaceUpdatesProvider = (*fakeUpdatesProvider)(nil) type fakeTunnelAuth struct{} -func (*fakeTunnelAuth) AuthorizeByID(_ context.Context, workspaceID uuid.UUID) error { - if workspaceID[0] != 1 { +// AuthorizeTunnel implements tailnet.TunnelAuthorizer. +func (*fakeTunnelAuth) AuthorizeTunnel(_ context.Context, agentID uuid.UUID) error { + if agentID[0] != 1 { return xerrors.New("policy disallows request") } return nil diff --git a/tailnet/tunnel.go b/tailnet/tunnel.go index 08dfab7f7788c..c1335f4c17d01 100644 --- a/tailnet/tunnel.go +++ b/tailnet/tunnel.go @@ -40,24 +40,7 @@ func (c ClientCoordinateeAuth) Authorize(_ context.Context, req *proto.Coordinat } } - if upd := req.GetUpdateSelf(); upd != nil { - for _, addrStr := range upd.Node.Addresses { - pre, err := netip.ParsePrefix(addrStr) - if err != nil { - return xerrors.Errorf("parse node address: %w", err) - } - - if pre.Bits() != 128 { - return xerrors.Errorf("invalid address bits, expected 128, got %d", pre.Bits()) - } - } - } - - if rfh := req.GetReadyForHandshake(); rfh != nil { - return xerrors.Errorf("clients may not send ready_for_handshake") - } - - return nil + return handleClientNodeRequests(req) } // AgentCoordinateeAuth disallows all tunnels, since agents are not allowed to initiate their own tunnels @@ -93,7 +76,7 @@ func (a AgentCoordinateeAuth) Authorize(_ context.Context, req *proto.Coordinate } type ClientUserCoordinateeAuth struct { - RBACAuth TunnelAuthorizer + Auth TunnelAuthorizer } func (a ClientUserCoordinateeAuth) Authorize(ctx context.Context, req *proto.CoordinateRequest) error { @@ -102,12 +85,17 @@ func (a ClientUserCoordinateeAuth) Authorize(ctx context.Context, req *proto.Coo if err != nil { return xerrors.Errorf("parse add tunnel id: %w", err) } - err = a.RBACAuth.AuthorizeByID(ctx, uid) + err = a.Auth.AuthorizeTunnel(ctx, uid) if err != nil { return xerrors.Errorf("workspace agent not found or you do not have permission") } } + return handleClientNodeRequests(req) +} + +// handleClientNodeRequests validates GetUpdateSelf requests and declines ReadyForHandshake requests +func handleClientNodeRequests(req *proto.CoordinateRequest) error { if upd := req.GetUpdateSelf(); upd != nil { for _, addrStr := range upd.Node.Addresses { pre, err := netip.ParsePrefix(addrStr) From b5090994b9e86e9be341742f900b520019cf7994 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 29 Oct 2024 09:51:52 +0000 Subject: [PATCH 7/7] review --- cli/server.go | 7 -- coderd/coderd.go | 10 ++- coderd/coderdtest/coderdtest.go | 17 ----- coderd/workspaceupdates.go | 36 +++++----- coderd/workspaceupdates_test.go | 78 ++++++++++++++------- enterprise/tailnet/connio.go | 2 +- enterprise/tailnet/pgcoord_test.go | 36 ++++++++++ tailnet/coordinator_test.go | 33 +++++++++ tailnet/service.go | 17 +---- tailnet/service_test.go | 106 ++++++++++++++++++----------- tailnet/test/peer.go | 17 +++++ 11 files changed, 231 insertions(+), 128 deletions(-) diff --git a/cli/server.go b/cli/server.go index 2154418eedf39..c053d8dc7ef02 100644 --- a/cli/server.go +++ b/cli/server.go @@ -728,13 +728,6 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. options.Database = dbmetrics.NewDBMetrics(options.Database, options.Logger, options.PrometheusRegistry) } - wsUpdates, err := coderd.NewUpdatesProvider(logger.Named("workspace_updates"), options.Pubsub, options.Database, options.Authorizer) - if err != nil { - return xerrors.Errorf("create workspace updates provider: %w", err) - } - options.WorkspaceUpdatesProvider = wsUpdates - defer wsUpdates.Close() - var deploymentID string err = options.Database.InTx(func(tx database.Store) error { // This will block until the lock is acquired, and will be diff --git a/coderd/coderd.go b/coderd/coderd.go index ded06918cceda..39df674fecca8 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -227,8 +227,6 @@ type Options struct { WorkspaceAppsStatsCollectorOptions workspaceapps.StatsCollectorOptions - WorkspaceUpdatesProvider tailnet.WorkspaceUpdatesProvider - // This janky function is used in telemetry to parse fields out of the raw // JWT. It needs to be passed through like this because license parsing is // under the enterprise license, and can't be imported into AGPL. @@ -495,6 +493,8 @@ func New(options *Options) *API { } } + updatesProvider := NewUpdatesProvider(options.Logger.Named("workspace_updates"), options.Pubsub, options.Database, options.Authorizer) + // Start a background process that rotates keys. We intentionally start this after the caches // are created to force initial requests for a key to populate the caches. This helps catch // bugs that may only occur when a key isn't precached in tests and the latency cost is minimal. @@ -525,6 +525,7 @@ func New(options *Options) *API { metricsCache: metricsCache, Auditor: atomic.Pointer[audit.Auditor]{}, TailnetCoordinator: atomic.Pointer[tailnet.Coordinator]{}, + UpdatesProvider: updatesProvider, TemplateScheduleStore: options.TemplateScheduleStore, UserQuietHoursScheduleStore: options.UserQuietHoursScheduleStore, AccessControlStore: options.AccessControlStore, @@ -660,7 +661,7 @@ func New(options *Options) *API { DERPMapFn: api.DERPMap, NetworkTelemetryHandler: api.NetworkTelemetryBatcher.Handler, ResumeTokenProvider: api.Options.CoordinatorResumeTokenProvider, - WorkspaceUpdatesProvider: api.Options.WorkspaceUpdatesProvider, + WorkspaceUpdatesProvider: api.UpdatesProvider, }) if err != nil { api.Logger.Fatal(context.Background(), "failed to initialize tailnet client service", slog.Error(err)) @@ -1415,6 +1416,8 @@ type API struct { AccessControlStore *atomic.Pointer[dbauthz.AccessControlStore] PortSharer atomic.Pointer[portsharing.PortSharer] + UpdatesProvider tailnet.WorkspaceUpdatesProvider + HTTPAuth *HTTPAuthorizer // APIHandler serves "/api/v2" @@ -1496,6 +1499,7 @@ func (api *API) Close() error { _ = api.OIDCConvertKeyCache.Close() _ = api.AppSigningKeyCache.Close() _ = api.AppEncryptionKeyCache.Close() + _ = api.UpdatesProvider.Close() return nil } diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 775e8764b622e..f3868bf14d54b 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -163,8 +163,6 @@ type Options struct { APIKeyEncryptionCache cryptokeys.EncryptionKeycache OIDCConvertKeyCache cryptokeys.SigningKeycache Clock quartz.Clock - - WorkspaceUpdatesProvider tailnet.WorkspaceUpdatesProvider } // New constructs a codersdk client connected to an in-memory API instance. @@ -256,20 +254,6 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can options.NotificationsEnqueuer = new(testutil.FakeNotificationsEnqueuer) } - if options.WorkspaceUpdatesProvider == nil { - var err error - options.WorkspaceUpdatesProvider, err = coderd.NewUpdatesProvider( - options.Logger.Named("workspace_updates"), - options.Pubsub, - options.Database, - options.Authorizer, - ) - require.NoError(t, err) - t.Cleanup(func() { - _ = options.WorkspaceUpdatesProvider.Close() - }) - } - accessControlStore := &atomic.Pointer[dbauthz.AccessControlStore]{} var acs dbauthz.AccessControlStore = dbauthz.AGPLTemplateAccessControlStore{} accessControlStore.Store(&acs) @@ -547,7 +531,6 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can HealthcheckTimeout: options.HealthcheckTimeout, HealthcheckRefresh: options.HealthcheckRefresh, StatsBatcher: options.StatsBatcher, - WorkspaceUpdatesProvider: options.WorkspaceUpdatesProvider, WorkspaceAppsStatsCollectorOptions: options.WorkspaceAppsStatsCollectorOptions, AllowWorkspaceRenames: options.AllowWorkspaceRenames, NewTicker: options.NewTicker, diff --git a/coderd/workspaceupdates.go b/coderd/workspaceupdates.go index eca63d23964d8..630a4be49ec6b 100644 --- a/coderd/workspaceupdates.go +++ b/coderd/workspaceupdates.go @@ -14,7 +14,6 @@ import ( "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/pubsub" "github.com/coder/coder/v2/coderd/rbac" - "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/coderd/wspubsub" "github.com/coder/coder/v2/codersdk" @@ -23,7 +22,8 @@ import ( ) type UpdatesQuerier interface { - GetAuthorizedWorkspacesAndAgentsByOwnerID(ctx context.Context, ownerID uuid.UUID, prep rbac.PreparedAuthorized) ([]database.GetWorkspacesAndAgentsByOwnerIDRow, error) + // GetAuthorizedWorkspacesAndAgentsByOwnerID requires a context with an actor set + GetWorkspacesAndAgentsByOwnerID(ctx context.Context, ownerID uuid.UUID) ([]database.GetWorkspacesAndAgentsByOwnerIDRow, error) GetWorkspaceByAgentID(ctx context.Context, agentID uuid.UUID) (database.Workspace, error) } @@ -42,14 +42,14 @@ func (w ownedWorkspace) Equal(other ownedWorkspace) bool { } type sub struct { + // ALways contains an actor ctx context.Context cancelFn context.CancelFunc - mu sync.RWMutex - userID uuid.UUID - ch chan *proto.WorkspaceUpdate - prev workspacesByID - readPrep rbac.PreparedAuthorized + mu sync.RWMutex + userID uuid.UUID + ch chan *proto.WorkspaceUpdate + prev workspacesByID db UpdatesQuerier ps pubsub.Pubsub @@ -76,7 +76,8 @@ func (s *sub) handleEvent(ctx context.Context, event wspubsub.WorkspaceEvent, er } } - rows, err := s.db.GetAuthorizedWorkspacesAndAgentsByOwnerID(ctx, s.userID, s.readPrep) + // Use context containing actor + rows, err := s.db.GetWorkspacesAndAgentsByOwnerID(s.ctx, s.userID) if err != nil { s.logger.Warn(ctx, "failed to get workspaces and agents by owner ID", slog.Error(err)) return @@ -97,7 +98,7 @@ func (s *sub) handleEvent(ctx context.Context, event wspubsub.WorkspaceEvent, er } func (s *sub) start(ctx context.Context) (err error) { - rows, err := s.db.GetAuthorizedWorkspacesAndAgentsByOwnerID(ctx, s.userID, s.readPrep) + rows, err := s.db.GetWorkspacesAndAgentsByOwnerID(ctx, s.userID) if err != nil { return xerrors.Errorf("get workspaces and agents by owner ID: %w", err) } @@ -150,7 +151,7 @@ func NewUpdatesProvider( ps pubsub.Pubsub, db UpdatesQuerier, auth rbac.Authorizer, -) (tailnet.WorkspaceUpdatesProvider, error) { +) tailnet.WorkspaceUpdatesProvider { ctx, cancel := context.WithCancel(context.Background()) out := &updatesProvider{ auth: auth, @@ -160,7 +161,7 @@ func NewUpdatesProvider( ctx: ctx, cancelFn: cancel, } - return out, nil + return out } func (u *updatesProvider) Close() error { @@ -168,17 +169,17 @@ func (u *updatesProvider) Close() error { return nil } +// Subscribe subscribes to workspace updates for a user, for the workspaces +// that user is authorized to `ActionRead` on. The provided context must have +// a dbauthz actor set. func (u *updatesProvider) Subscribe(ctx context.Context, userID uuid.UUID) (tailnet.Subscription, error) { actor, ok := dbauthz.ActorFromContext(ctx) if !ok { return nil, xerrors.Errorf("actor not found in context") } - readPrep, err := u.auth.Prepare(ctx, actor, policy.ActionRead, rbac.ResourceWorkspace.Type) - if err != nil { - return nil, xerrors.Errorf("prepare read action: %w", err) - } + ctx, cancel := context.WithCancel(u.ctx) + ctx = dbauthz.As(ctx, actor) ch := make(chan *proto.WorkspaceUpdate, 1) - ctx, cancel := context.WithCancel(ctx) sub := &sub{ ctx: ctx, cancelFn: cancel, @@ -188,9 +189,8 @@ func (u *updatesProvider) Subscribe(ctx context.Context, userID uuid.UUID) (tail ps: u.ps, logger: u.logger.Named(fmt.Sprintf("workspace_updates_subscriber_%s", userID)), prev: workspacesByID{}, - readPrep: readPrep, } - err = sub.start(ctx) + err := sub.start(ctx) if err != nil { _ = sub.Close() return nil, err diff --git a/coderd/workspaceupdates_test.go b/coderd/workspaceupdates_test.go index 3e6bd8f03719c..7c01e6611f873 100644 --- a/coderd/workspaceupdates_test.go +++ b/coderd/workspaceupdates_test.go @@ -25,22 +25,23 @@ import ( func TestWorkspaceUpdates(t *testing.T) { t.Parallel() - ctx := context.Background() - ws1ID := uuid.New() + ws1ID := uuid.UUID{0x01} ws1IDSlice := tailnet.UUIDToByteSlice(ws1ID) - agent1ID := uuid.New() + agent1ID := uuid.UUID{0x02} agent1IDSlice := tailnet.UUIDToByteSlice(agent1ID) - ws2ID := uuid.New() + ws2ID := uuid.UUID{0x03} ws2IDSlice := tailnet.UUIDToByteSlice(ws2ID) - ws3ID := uuid.New() + ws3ID := uuid.UUID{0x04} ws3IDSlice := tailnet.UUIDToByteSlice(ws3ID) - agent2ID := uuid.New() + agent2ID := uuid.UUID{0x05} agent2IDSlice := tailnet.UUIDToByteSlice(agent2ID) - ws4ID := uuid.New() + ws4ID := uuid.UUID{0x06} ws4IDSlice := tailnet.UUIDToByteSlice(ws4ID) + agent3ID := uuid.UUID{0x07} + agent3IDSlice := tailnet.UUIDToByteSlice(agent3ID) - ownerID := uuid.New() + ownerID := uuid.UUID{0x08} memberRole, err := rbac.RoleByName(rbac.RoleMember()) require.NoError(t, err) ownerSubject := rbac.Subject{ @@ -53,9 +54,11 @@ func TestWorkspaceUpdates(t *testing.T) { t.Run("Basic", func(t *testing.T) { t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + db := &mockWorkspaceStore{ orderedRows: []database.GetWorkspacesAndAgentsByOwnerIDRow{ - // Gains a new agent + // Gains agent2 { ID: ws1ID, Name: "ws1", @@ -81,6 +84,12 @@ func TestWorkspaceUpdates(t *testing.T) { Name: "ws3", JobStatus: database.ProvisionerJobStatusSucceeded, Transition: database.WorkspaceTransitionStop, + Agents: []database.AgentIDNamePair{ + { + ID: agent3ID, + Name: "agent3", + }, + }, }, }, } @@ -89,21 +98,24 @@ func TestWorkspaceUpdates(t *testing.T) { cbs: map[string]pubsub.ListenerWithErr{}, } - updateProvider, err := coderd.NewUpdatesProvider(slogtest.Make(t, nil), ps, db, &mockAuthorizer{}) - require.NoError(t, err) + updateProvider := coderd.NewUpdatesProvider(slogtest.Make(t, nil), ps, db, &mockAuthorizer{}) t.Cleanup(func() { _ = updateProvider.Close() }) sub, err := updateProvider.Subscribe(dbauthz.As(ctx, ownerSubject), ownerID) require.NoError(t, err) - ch := sub.Updates() + t.Cleanup(func() { + _ = sub.Close() + }) - update, ok := <-ch - require.True(t, ok) + update := testutil.RequireRecvCtx(ctx, t, sub.Updates()) slices.SortFunc(update.UpsertedWorkspaces, func(a, b *proto.Workspace) int { return strings.Compare(a.Name, b.Name) }) + slices.SortFunc(update.UpsertedAgents, func(a, b *proto.Agent) int { + return strings.Compare(a.Name, b.Name) + }) require.Equal(t, &proto.WorkspaceUpdate{ UpsertedWorkspaces: []*proto.Workspace{ { @@ -128,6 +140,11 @@ func TestWorkspaceUpdates(t *testing.T) { Name: "agent1", WorkspaceId: ws1IDSlice, }, + { + Id: agent3IDSlice, + Name: "agent3", + WorkspaceId: ws3IDSlice, + }, }, DeletedWorkspaces: []*proto.Workspace{}, DeletedAgents: []*proto.Agent{}, @@ -169,8 +186,7 @@ func TestWorkspaceUpdates(t *testing.T) { WorkspaceID: ws1ID, }) - update, ok = <-ch - require.True(t, ok) + update = testutil.RequireRecvCtx(ctx, t, sub.Updates()) slices.SortFunc(update.UpsertedWorkspaces, func(a, b *proto.Workspace) int { return strings.Compare(a.Name, b.Name) }) @@ -203,13 +219,21 @@ func TestWorkspaceUpdates(t *testing.T) { Status: proto.Workspace_STOPPED, }, }, - DeletedAgents: []*proto.Agent{}, + DeletedAgents: []*proto.Agent{ + { + Id: agent3IDSlice, + Name: "agent3", + WorkspaceId: ws3IDSlice, + }, + }, }, update) }) t.Run("Resubscribe", func(t *testing.T) { t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + db := &mockWorkspaceStore{ orderedRows: []database.GetWorkspacesAndAgentsByOwnerIDRow{ { @@ -231,15 +255,16 @@ func TestWorkspaceUpdates(t *testing.T) { cbs: map[string]pubsub.ListenerWithErr{}, } - updateProvider, err := coderd.NewUpdatesProvider(slogtest.Make(t, nil), ps, db, &mockAuthorizer{}) - require.NoError(t, err) + updateProvider := coderd.NewUpdatesProvider(slogtest.Make(t, nil), ps, db, &mockAuthorizer{}) t.Cleanup(func() { _ = updateProvider.Close() }) sub, err := updateProvider.Subscribe(dbauthz.As(ctx, ownerSubject), ownerID) require.NoError(t, err) - ch := sub.Updates() + t.Cleanup(func() { + _ = sub.Close() + }) expected := &proto.WorkspaceUpdate{ UpsertedWorkspaces: []*proto.Workspace{ @@ -260,18 +285,19 @@ func TestWorkspaceUpdates(t *testing.T) { DeletedAgents: []*proto.Agent{}, } - update := testutil.RequireRecvCtx(ctx, t, ch) + update := testutil.RequireRecvCtx(ctx, t, sub.Updates()) slices.SortFunc(update.UpsertedWorkspaces, func(a, b *proto.Workspace) int { return strings.Compare(a.Name, b.Name) }) require.Equal(t, expected, update) + resub, err := updateProvider.Subscribe(dbauthz.As(ctx, ownerSubject), ownerID) require.NoError(t, err) - sub, err = updateProvider.Subscribe(dbauthz.As(ctx, ownerSubject), ownerID) - require.NoError(t, err) - ch = sub.Updates() + t.Cleanup(func() { + _ = resub.Close() + }) - update = testutil.RequireRecvCtx(ctx, t, ch) + update = testutil.RequireRecvCtx(ctx, t, resub.Updates()) slices.SortFunc(update.UpsertedWorkspaces, func(a, b *proto.Workspace) int { return strings.Compare(a.Name, b.Name) }) @@ -290,7 +316,7 @@ type mockWorkspaceStore struct { } // GetAuthorizedWorkspacesAndAgentsByOwnerID implements coderd.UpdatesQuerier. -func (m *mockWorkspaceStore) GetAuthorizedWorkspacesAndAgentsByOwnerID(context.Context, uuid.UUID, rbac.PreparedAuthorized) ([]database.GetWorkspacesAndAgentsByOwnerIDRow, error) { +func (m *mockWorkspaceStore) GetWorkspacesAndAgentsByOwnerID(context.Context, uuid.UUID) ([]database.GetWorkspacesAndAgentsByOwnerIDRow, error) { return m.orderedRows, nil } diff --git a/enterprise/tailnet/connio.go b/enterprise/tailnet/connio.go index 17f397aa5f1d1..923af4bee080d 100644 --- a/enterprise/tailnet/connio.go +++ b/enterprise/tailnet/connio.go @@ -133,7 +133,7 @@ var errDisconnect = xerrors.New("graceful disconnect") func (c *connIO) handleRequest(req *proto.CoordinateRequest) error { c.logger.Debug(c.peerCtx, "got request") - err := c.auth.Authorize(c.coordCtx, req) + err := c.auth.Authorize(c.peerCtx, req) if err != nil { c.logger.Warn(c.peerCtx, "unauthorized request", slog.Error(err)) return xerrors.Errorf("authorize request: %w", err) diff --git a/enterprise/tailnet/pgcoord_test.go b/enterprise/tailnet/pgcoord_test.go index 08c0017a2d1bd..c0d122aa74992 100644 --- a/enterprise/tailnet/pgcoord_test.go +++ b/enterprise/tailnet/pgcoord_test.go @@ -913,6 +913,42 @@ func TestPGCoordinatorDual_PeerReconnect(t *testing.T) { p2.AssertNeverUpdateKind(p1.ID, proto.CoordinateResponse_PeerUpdate_DISCONNECTED) } +// TestPGCoordinatorPropogatedPeerContext tests that the context for a specific peer +// is propogated through to the `Authorize` method of the coordinatee auth +func TestPGCoordinatorPropogatedPeerContext(t *testing.T) { + t.Parallel() + + if !dbtestutil.WillUsePostgres() { + t.Skip("test only with postgres") + } + + ctx := testutil.Context(t, testutil.WaitShort) + store, ps := dbtestutil.NewDB(t) + logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug) + + peerCtx := context.WithValue(ctx, agpltest.FakeSubjectKey{}, struct{}{}) + peerID := uuid.UUID{0x01} + agentID := uuid.UUID{0x02} + + c1, err := tailnet.NewPGCoord(ctx, logger, ps, store) + require.NoError(t, err) + defer func() { + err := c1.Close() + require.NoError(t, err) + }() + + ch := make(chan struct{}) + auth := agpltest.FakeCoordinateeAuth{ + Chan: ch, + } + + reqs, _ := c1.Coordinate(peerCtx, peerID, "peer1", auth) + + testutil.RequireSendCtx(ctx, t, reqs, &proto.CoordinateRequest{AddTunnel: &proto.CoordinateRequest_Tunnel{Id: agpl.UUIDToByteSlice(agentID)}}) + + _ = testutil.RequireRecvCtx(ctx, t, ch) +} + func assertEventuallyStatus(ctx context.Context, t *testing.T, store database.Store, agentID uuid.UUID, status database.TailnetStatus) { t.Helper() assert.Eventually(t, func() bool { diff --git a/tailnet/coordinator_test.go b/tailnet/coordinator_test.go index 918b9c7b0533c..b3a803cd6aaf6 100644 --- a/tailnet/coordinator_test.go +++ b/tailnet/coordinator_test.go @@ -529,3 +529,36 @@ func (f *fakeCoordinatee) SetNodeCallback(callback func(*tailnet.Node)) { defer f.Unlock() f.callback = callback } + +// TestCoordinatorPropogatedPeerContext tests that the context for a specific peer +// is propogated through to the `Authorizeā€œ method of the coordinatee auth +func TestCoordinatorPropogatedPeerContext(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug) + + peerCtx := context.WithValue(ctx, test.FakeSubjectKey{}, struct{}{}) + peerCtx, peerCtxCancel := context.WithCancel(peerCtx) + peerID := uuid.UUID{0x01} + agentID := uuid.UUID{0x02} + + c1 := tailnet.NewCoordinator(logger) + t.Cleanup(func() { + err := c1.Close() + require.NoError(t, err) + }) + + ch := make(chan struct{}) + auth := test.FakeCoordinateeAuth{ + Chan: ch, + } + + reqs, _ := c1.Coordinate(peerCtx, peerID, "peer1", auth) + + testutil.RequireSendCtx(ctx, t, reqs, &proto.CoordinateRequest{AddTunnel: &proto.CoordinateRequest_Tunnel{Id: tailnet.UUIDToByteSlice(agentID)}}) + _ = testutil.RequireRecvCtx(ctx, t, ch) + // If we don't cancel the context, the coordinator close will wait until the + // peer request loop finishes, which will be after the timeout + peerCtxCancel() +} diff --git a/tailnet/service.go b/tailnet/service.go index 35c9cf2607d5d..cfbbb77a9833f 100644 --- a/tailnet/service.go +++ b/tailnet/service.go @@ -220,28 +220,15 @@ func (s *DRPCService) WorkspaceUpdates(req *proto.WorkspaceUpdatesRequest, strea defer stream.Close() ctx := stream.Context() - streamID, ok := ctx.Value(streamIDContextKey{}).(StreamID) - if !ok { - return xerrors.New("no Stream ID") - } ownerID, err := uuid.FromBytes(req.WorkspaceOwnerId) if err != nil { return xerrors.Errorf("parse workspace owner ID: %w", err) } - var sub Subscription - switch auth := streamID.Auth.(type) { - case ClientUserCoordinateeAuth: - sub, err = s.WorkspaceUpdatesProvider.Subscribe(ctx, ownerID) - if err != nil { - err = xerrors.Errorf("subscribe to workspace updates: %w", err) - } - default: - err = xerrors.Errorf("workspace updates not supported by auth name %T", auth) - } + sub, err := s.WorkspaceUpdatesProvider.Subscribe(ctx, ownerID) if err != nil { - return err + return xerrors.Errorf("subscribe to workspace updates: %w", err) } defer sub.Close() diff --git a/tailnet/service_test.go b/tailnet/service_test.go index 50275301f6f02..f5a01cc2fbacc 100644 --- a/tailnet/service_test.go +++ b/tailnet/service_test.go @@ -227,45 +227,19 @@ func TestNetworkTelemetryBatcher(t *testing.T) { require.Equal(t, "6", string(batch[1].Id)) } -func TestWorkspaceUpdates(t *testing.T) { +func TestClientUserCoordinateeAuth(t *testing.T) { t.Parallel() - fCoord := tailnettest.NewFakeCoordinator() - var coord tailnet.Coordinator = fCoord - coordPtr := atomic.Pointer[tailnet.Coordinator]{} - coordPtr.Store(&coord) - logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug) + ctx := testutil.Context(t, testutil.WaitShort) + + agentID := uuid.UUID{0x01} + agentID2 := uuid.UUID{0x02} + clientID := uuid.UUID{0x03} updatesCh := make(chan *proto.WorkspaceUpdate, 1) updatesProvider := &fakeUpdatesProvider{ch: updatesCh} - uut, err := tailnet.NewClientService(tailnet.ClientServiceOptions{ - Logger: logger, - CoordPtr: &coordPtr, - WorkspaceUpdatesProvider: updatesProvider, - }) - require.NoError(t, err) - - ctx := testutil.Context(t, testutil.WaitShort) - c, s := net.Pipe() - defer c.Close() - defer s.Close() - clientID := uuid.New() - errCh := make(chan error, 1) - go func() { - err := uut.ServeClient(ctx, "2.0", s, tailnet.StreamID{ - Name: "client", - ID: clientID, - Auth: tailnet.ClientUserCoordinateeAuth{ - Auth: &fakeTunnelAuth{}, - }, - }) - t.Logf("ServeClient returned; err=%v", err) - errCh <- err - }() - - client, err := tailnet.NewDRPCClient(c, logger) - require.NoError(t, err) + fCoord, client := createUpdateService(t, ctx, clientID, updatesProvider) // Coordinate stream, err := client.Coordinate(ctx) @@ -285,22 +259,31 @@ func TestWorkspaceUpdates(t *testing.T) { require.Equal(t, int32(11), req.GetUpdateSelf().GetNode().GetPreferredDerp()) // Authorize uses `ClientUserCoordinateeAuth` - agentID := uuid.New() - agentID[0] = 1 require.NoError(t, call.Auth.Authorize(ctx, &proto.CoordinateRequest{ AddTunnel: &proto.CoordinateRequest_Tunnel{Id: tailnet.UUIDToByteSlice(agentID)}, })) - agentID2 := uuid.New() - agentID2[0] = 2 require.Error(t, call.Auth.Authorize(ctx, &proto.CoordinateRequest{ AddTunnel: &proto.CoordinateRequest_Tunnel{Id: tailnet.UUIDToByteSlice(agentID2)}, })) +} + +func TestWorkspaceUpdates(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + updatesCh := make(chan *proto.WorkspaceUpdate, 1) + updatesProvider := &fakeUpdatesProvider{ch: updatesCh} + + clientID := uuid.UUID{0x03} + wsID := uuid.UUID{0x04} + + _, client := createUpdateService(t, ctx, clientID, updatesProvider) // Workspace updates expected := &proto.WorkspaceUpdate{ UpsertedWorkspaces: []*proto.Workspace{ { - Id: tailnet.UUIDToByteSlice(uuid.New()), + Id: tailnet.UUIDToByteSlice(wsID), Name: "ws1", Status: proto.Workspace_RUNNING, }, @@ -323,11 +306,52 @@ func TestWorkspaceUpdates(t *testing.T) { require.Equal(t, expected.GetUpsertedWorkspaces()[0].GetName(), updates.GetUpsertedWorkspaces()[0].GetName()) require.Equal(t, expected.GetUpsertedWorkspaces()[0].GetStatus(), updates.GetUpsertedWorkspaces()[0].GetStatus()) require.Equal(t, expected.GetUpsertedWorkspaces()[0].GetId(), updates.GetUpsertedWorkspaces()[0].GetId()) +} - err = c.Close() +//nolint:revive // t takes precedence +func createUpdateService(t *testing.T, ctx context.Context, clientID uuid.UUID, updates tailnet.WorkspaceUpdatesProvider) (*tailnettest.FakeCoordinator, proto.DRPCTailnetClient) { + fCoord := tailnettest.NewFakeCoordinator() + var coord tailnet.Coordinator = fCoord + coordPtr := atomic.Pointer[tailnet.Coordinator]{} + coordPtr.Store(&coord) + logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug) + + uut, err := tailnet.NewClientService(tailnet.ClientServiceOptions{ + Logger: logger, + CoordPtr: &coordPtr, + WorkspaceUpdatesProvider: updates, + }) require.NoError(t, err) - err = testutil.RequireRecvCtx(ctx, t, errCh) - require.True(t, xerrors.Is(err, io.EOF) || xerrors.Is(err, io.ErrClosedPipe)) + + c, s := net.Pipe() + t.Cleanup(func() { + _ = c.Close() + _ = s.Close() + }) + + errCh := make(chan error, 1) + go func() { + err := uut.ServeClient(ctx, "2.0", s, tailnet.StreamID{ + Name: "client", + ID: clientID, + Auth: tailnet.ClientUserCoordinateeAuth{ + Auth: &fakeTunnelAuth{}, + }, + }) + t.Logf("ServeClient returned; err=%v", err) + errCh <- err + }() + + client, err := tailnet.NewDRPCClient(c, logger) + require.NoError(t, err) + + t.Cleanup(func() { + err = c.Close() + require.NoError(t, err) + err = testutil.RequireRecvCtx(ctx, t, errCh) + require.True(t, xerrors.Is(err, io.EOF) || xerrors.Is(err, io.ErrClosedPipe)) + }) + return fCoord, client } type fakeUpdatesProvider struct { diff --git a/tailnet/test/peer.go b/tailnet/test/peer.go index ce9a50749901f..9426beac860b7 100644 --- a/tailnet/test/peer.go +++ b/tailnet/test/peer.go @@ -370,3 +370,20 @@ func (p *Peer) UngracefulDisconnect(ctx context.Context) { close(p.reqs) p.Close(ctx) } + +type FakeSubjectKey struct{} + +type FakeCoordinateeAuth struct { + Chan chan struct{} +} + +func (f FakeCoordinateeAuth) Authorize(ctx context.Context, _ *proto.CoordinateRequest) error { + _, ok := ctx.Value(FakeSubjectKey{}).(struct{}) + if !ok { + return xerrors.New("unauthorized") + } + f.Chan <- struct{}{} + return nil +} + +var _ tailnet.CoordinateeAuth = (*FakeCoordinateeAuth)(nil)