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) pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy