From 0760f8d00d2f0ecbcd6c1c4533596f3e912d714f Mon Sep 17 00:00:00 2001 From: Garrett Date: Mon, 16 May 2022 21:25:29 +0000 Subject: [PATCH 01/13] chore: Add watch workspace endpoint --- coderd/coderd.go | 1 + coderd/workspaces.go | 92 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/coderd/coderd.go b/coderd/coderd.go index 1f1a4ff18dfac..a639982248596 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -311,6 +311,7 @@ func New(options *Options) (http.Handler, func()) { r.Put("/", api.putWorkspaceAutostop) }) }) + r.HandleFunc("/watch", api.watchWorkspace) }) r.Route("/workspacebuilds/{workspacebuild}", func(r chi.Router) { r.Use( diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 4ef3b61301fe6..7f431b7c37b79 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -7,12 +7,16 @@ import ( "errors" "fmt" "net/http" + "time" + "cdr.dev/slog" "github.com/go-chi/chi/v5" "github.com/google/uuid" "github.com/moby/moby/pkg/namesgenerator" "golang.org/x/sync/errgroup" "golang.org/x/xerrors" + "nhooyr.io/websocket" + "nhooyr.io/websocket/wsjson" "github.com/coder/coder/coderd/autobuild/schedule" "github.com/coder/coder/coderd/database" @@ -535,6 +539,94 @@ func (api *api) putWorkspaceAutostop(rw http.ResponseWriter, r *http.Request) { } } +func (api *api) watchWorkspace(rw http.ResponseWriter, r *http.Request) { + workspace := httpmw.WorkspaceParam(r) + + c, err := websocket.Accept(rw, r, &websocket.AcceptOptions{ + // Fix for Safari 15.1: + // There is a bug in latest Safari in which compressed web socket traffic + // isn't handled correctly. Turning off compression is a workaround: + // https://github.com/nhooyr/websocket/issues/218 + CompressionMode: websocket.CompressionDisabled, + }) + if err != nil { + api.Logger.Warn(r.Context(), "accept websocket connection", slog.Error(err)) + return + } + defer c.Close(websocket.StatusInternalError, "internal error") + + ctx := c.CloseRead(r.Context()) + + // Send a heartbeat every 15 seconds to avoid the websocket being killed. + go func() { + ticker := time.NewTicker(time.Second * 15) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + err := c.Ping(ctx) + if err != nil { + return + } + } + } + }() + + t := time.NewTicker(time.Second * 1) + defer t.Stop() + for { + select { + case <-t.C: + workspace, err := api.Database.GetWorkspaceByID(r.Context(), workspace.ID) + if err != nil { + _ = wsjson.Write(ctx, c, httpapi.Response{ + Message: fmt.Sprintf("get workspace: %s", err), + }) + return + } + build, err := api.Database.GetWorkspaceBuildByWorkspaceIDWithoutAfter(r.Context(), workspace.ID) + if err != nil { + _ = wsjson.Write(ctx, c, httpapi.Response{ + Message: fmt.Sprintf("get workspace build: %s", err), + }) + return + } + var ( + group errgroup.Group + job database.ProvisionerJob + template database.Template + owner database.User + ) + group.Go(func() (err error) { + job, err = api.Database.GetProvisionerJobByID(r.Context(), build.JobID) + return err + }) + group.Go(func() (err error) { + template, err = api.Database.GetTemplateByID(r.Context(), workspace.TemplateID) + return err + }) + group.Go(func() (err error) { + owner, err = api.Database.GetUserByID(r.Context(), workspace.OwnerID) + return err + }) + err = group.Wait() + if err != nil { + _ = wsjson.Write(ctx, c, httpapi.Response{ + Message: fmt.Sprintf("fetch resource: %s", err), + }) + return + } + + _ = wsjson.Write(ctx, c, convertWorkspace(workspace, convertWorkspaceBuild(build, convertProvisionerJob(job)), template, owner)) + case <-ctx.Done(): + return + } + } +} + func convertWorkspaces(ctx context.Context, db database.Store, workspaces []database.Workspace) ([]codersdk.Workspace, error) { workspaceIDs := make([]uuid.UUID, 0, len(workspaces)) templateIDs := make([]uuid.UUID, 0, len(workspaces)) From 0551613f81e07ad7b693063c1b95838674017300 Mon Sep 17 00:00:00 2001 From: Garrett Date: Mon, 16 May 2022 21:29:10 +0000 Subject: [PATCH 02/13] Add comment --- coderd/workspaces.go | 1 + 1 file changed, 1 insertion(+) diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 7f431b7c37b79..70379c4afe31c 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -555,6 +555,7 @@ func (api *api) watchWorkspace(rw http.ResponseWriter, r *http.Request) { } defer c.Close(websocket.StatusInternalError, "internal error") + // Makes the websocket connection write-only ctx := c.CloseRead(r.Context()) // Send a heartbeat every 15 seconds to avoid the websocket being killed. From da9c744fafed381c45f499354b1458288ab4961a Mon Sep 17 00:00:00 2001 From: Garrett Date: Mon, 16 May 2022 22:41:40 +0000 Subject: [PATCH 03/13] Make WorkspaceWatcher --- codersdk/client.go | 36 ++++++++++++++++++++++++++++++++++++ codersdk/workspaces.go | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/codersdk/client.go b/codersdk/client.go index 48571adff5d0b..1930c5c3b0b65 100644 --- a/codersdk/client.go +++ b/codersdk/client.go @@ -12,6 +12,7 @@ import ( "strings" "golang.org/x/xerrors" + "nhooyr.io/websocket" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/httpmw" @@ -80,6 +81,41 @@ func (c *Client) Request(ctx context.Context, method, path string, body interfac return resp, err } +// request performs an HTTP request with the body provided. +// The caller is responsible for closing the response body. +func (c *Client) websocket(ctx context.Context, path string) (*websocket.Conn, error) { + serverURL, err := c.URL.Parse(path) + if err != nil { + return nil, xerrors.Errorf("parse url: %w", err) + } + + apiURL, err := url.Parse(serverURL.String()) + apiURL.Scheme = "ws" + if serverURL.Scheme == "https" { + apiURL.Scheme = "wss" + } + apiURL.Path = path + + client := &http.Client{ + Jar: c.HTTPClient.Jar, + } + cookies := append(client.Jar.Cookies(c.URL), &http.Cookie{ + Name: httpmw.AuthCookie, + Value: c.SessionToken, + }) + client.Jar.SetCookies(c.URL, cookies) + + //nolint:bodyclose + conn, _, err := websocket.Dial(context.Background(), apiURL.String(), &websocket.DialOptions{ + HTTPClient: c.HTTPClient, + }) + if err != nil { + return nil, xerrors.Errorf("dial websocket: %w", err) + } + + return conn, nil +} + // readBodyAsError reads the response as an httpapi.Message, and // wraps it in a codersdk.Error type for easy marshaling. func readBodyAsError(res *http.Response) error { diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index 6e4ab7afd6e57..a2a241f59ae9e 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -9,6 +9,8 @@ import ( "github.com/google/uuid" "golang.org/x/xerrors" + "nhooyr.io/websocket" + "nhooyr.io/websocket/wsjson" "github.com/coder/coder/coderd/database" ) @@ -98,6 +100,40 @@ func (c *Client) WorkspaceBuildByName(ctx context.Context, workspace uuid.UUID, return workspaceBuild, json.NewDecoder(res.Body).Decode(&workspaceBuild) } +type WorkspaceWatcher struct { + conn *websocket.Conn +} + +func (w *WorkspaceWatcher) Read(ctx context.Context) (Workspace, error) { + var ws Workspace + err := wsjson.Read(ctx, w.conn, &ws) + if err != nil { + return ws, xerrors.Errorf("read workspace: %w") + } + + return ws, nil +} + +func (w *WorkspaceWatcher) Close() error { + err := w.conn.Close(websocket.StatusNormalClosure, "") + if err != nil { + return xerrors.Errorf("closing workspace watcher: %w", err) + } + + return nil +} + +func (c *Client) WorkspaceWatcher(ctx context.Context, id uuid.UUID) (*WorkspaceWatcher, error) { + conn, err := c.websocket(ctx, fmt.Sprintf("/api/v2/workspaces/%s/watch", id)) + if err != nil { + return nil, err + } + + return &WorkspaceWatcher{ + conn: conn, + }, nil +} + // UpdateWorkspaceAutostartRequest is a request to update a workspace's autostart schedule. type UpdateWorkspaceAutostartRequest struct { Schedule string `json:"schedule"` From dbaeb2cf596fe8f4a1d58f35010d9848e87b9eaf Mon Sep 17 00:00:00 2001 From: Garrett Date: Tue, 17 May 2022 20:10:40 +0000 Subject: [PATCH 04/13] add tests --- coderd/httpmw/apikey.go | 10 ++++++++-- coderd/workspaces_test.go | 25 +++++++++++++++++++++++++ codersdk/client.go | 16 +++++----------- codersdk/workspaces.go | 2 +- 4 files changed, 39 insertions(+), 14 deletions(-) diff --git a/coderd/httpmw/apikey.go b/coderd/httpmw/apikey.go index c3038ace73b6a..34754c67e0c6c 100644 --- a/coderd/httpmw/apikey.go +++ b/coderd/httpmw/apikey.go @@ -43,14 +43,20 @@ type OAuth2Configs struct { func ExtractAPIKey(db database.Store, oauth *OAuth2Configs) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + var cookieValue string cookie, err := r.Cookie(AuthCookie) if err != nil { + cookieValue = r.URL.Query().Get(AuthCookie) + } else { + cookieValue = cookie.Value + } + if cookieValue == "" { httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{ - Message: fmt.Sprintf("%q cookie must be provided", AuthCookie), + Message: fmt.Sprintf("%q cookie or query parameter must be provided", AuthCookie), }) return } - parts := strings.Split(cookie.Value, "-") + parts := strings.Split(cookieValue, "-") // APIKeys are formatted: ID-SECRET if len(parts) != 2 { httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{ diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 58140b1d00e1d..979cb784d2750 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -621,3 +621,28 @@ func mustLocation(t *testing.T, location string) *time.Location { return loc } + +func TestWorkspaceWatcher(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + coderdtest.NewProvisionerDaemon(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + w, err := client.Workspace(context.Background(), workspace.ID) + require.NoError(t, err) + + ww, err := client.WatchWorkspace(context.Background(), w.ID) + require.NoError(t, err) + defer ww.Close() + for i := 0; i < 5; i++ { + _, err := ww.Read(context.Background()) + require.NoError(t, err) + } + err = ww.Close() + require.NoError(t, err) + _, err = ww.Read(context.Background()) + require.Error(t, err) +} diff --git a/codersdk/client.go b/codersdk/client.go index 1930c5c3b0b65..fd7cc1199c18f 100644 --- a/codersdk/client.go +++ b/codersdk/client.go @@ -81,8 +81,8 @@ func (c *Client) Request(ctx context.Context, method, path string, body interfac return resp, err } -// request performs an HTTP request with the body provided. -// The caller is responsible for closing the response body. +// websocket opens a websocket connection on that path provided. +// The caller is responsible for closing the websocket.Conn. func (c *Client) websocket(ctx context.Context, path string) (*websocket.Conn, error) { serverURL, err := c.URL.Parse(path) if err != nil { @@ -95,15 +95,9 @@ func (c *Client) websocket(ctx context.Context, path string) (*websocket.Conn, e apiURL.Scheme = "wss" } apiURL.Path = path - - client := &http.Client{ - Jar: c.HTTPClient.Jar, - } - cookies := append(client.Jar.Cookies(c.URL), &http.Cookie{ - Name: httpmw.AuthCookie, - Value: c.SessionToken, - }) - client.Jar.SetCookies(c.URL, cookies) + q := apiURL.Query() + q.Add(httpmw.AuthCookie, c.SessionToken) + apiURL.RawQuery = q.Encode() //nolint:bodyclose conn, _, err := websocket.Dial(context.Background(), apiURL.String(), &websocket.DialOptions{ diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index a2a241f59ae9e..2b339d090f004 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -123,7 +123,7 @@ func (w *WorkspaceWatcher) Close() error { return nil } -func (c *Client) WorkspaceWatcher(ctx context.Context, id uuid.UUID) (*WorkspaceWatcher, error) { +func (c *Client) WatchWorkspace(ctx context.Context, id uuid.UUID) (*WorkspaceWatcher, error) { conn, err := c.websocket(ctx, fmt.Sprintf("/api/v2/workspaces/%s/watch", id)) if err != nil { return nil, err From 596cb8c5120f1058483a4c604220027b8884be6b Mon Sep 17 00:00:00 2001 From: Garrett Date: Tue, 17 May 2022 20:15:47 +0000 Subject: [PATCH 05/13] lint --- codersdk/client.go | 7 +++++-- codersdk/workspaces.go | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/codersdk/client.go b/codersdk/client.go index fd7cc1199c18f..f1fdfce9622f8 100644 --- a/codersdk/client.go +++ b/codersdk/client.go @@ -86,10 +86,13 @@ func (c *Client) Request(ctx context.Context, method, path string, body interfac func (c *Client) websocket(ctx context.Context, path string) (*websocket.Conn, error) { serverURL, err := c.URL.Parse(path) if err != nil { - return nil, xerrors.Errorf("parse url: %w", err) + return nil, xerrors.Errorf("parse path: %w", err) } apiURL, err := url.Parse(serverURL.String()) + if err != nil { + return nil, xerrors.Errorf("parse server url: %w", err) + } apiURL.Scheme = "ws" if serverURL.Scheme == "https" { apiURL.Scheme = "wss" @@ -100,7 +103,7 @@ func (c *Client) websocket(ctx context.Context, path string) (*websocket.Conn, e apiURL.RawQuery = q.Encode() //nolint:bodyclose - conn, _, err := websocket.Dial(context.Background(), apiURL.String(), &websocket.DialOptions{ + conn, _, err := websocket.Dial(ctx, apiURL.String(), &websocket.DialOptions{ HTTPClient: c.HTTPClient, }) if err != nil { diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index 2b339d090f004..31ed2a8dea67f 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -108,7 +108,7 @@ func (w *WorkspaceWatcher) Read(ctx context.Context) (Workspace, error) { var ws Workspace err := wsjson.Read(ctx, w.conn, &ws) if err != nil { - return ws, xerrors.Errorf("read workspace: %w") + return ws, xerrors.Errorf("read workspace: %w", err) } return ws, nil From 31b14adcac54093c317e6185431c46752341930a Mon Sep 17 00:00:00 2001 From: Garrett Date: Tue, 17 May 2022 20:28:15 +0000 Subject: [PATCH 06/13] Add test for query param session token --- coderd/httpmw/apikey.go | 16 +++---- coderd/httpmw/apikey_test.go | 53 +++++++++++++++++----- coderd/httpmw/authorize_test.go | 2 +- coderd/httpmw/organizationparam_test.go | 2 +- coderd/httpmw/templateparam_test.go | 2 +- coderd/httpmw/templateversionparam_test.go | 2 +- coderd/httpmw/userparam_test.go | 2 +- coderd/httpmw/workspaceagent.go | 4 +- coderd/httpmw/workspaceagent_test.go | 2 +- coderd/httpmw/workspaceagentparam_test.go | 2 +- coderd/httpmw/workspacebuildparam_test.go | 2 +- coderd/httpmw/workspaceparam_test.go | 2 +- coderd/users.go | 4 +- coderd/users_test.go | 2 +- codersdk/client.go | 4 +- codersdk/workspaceagents.go | 6 +-- 16 files changed, 69 insertions(+), 38 deletions(-) diff --git a/coderd/httpmw/apikey.go b/coderd/httpmw/apikey.go index 34754c67e0c6c..abf55128d55d7 100644 --- a/coderd/httpmw/apikey.go +++ b/coderd/httpmw/apikey.go @@ -17,8 +17,8 @@ import ( "github.com/coder/coder/coderd/httpapi" ) -// AuthCookie represents the name of the cookie the API key is stored in. -const AuthCookie = "session_token" +// SessionTokenKey represents the name of the cookie or query paramater the API key is stored in. +const SessionTokenKey = "session_token" type apiKeyContextKey struct{} @@ -44,15 +44,15 @@ func ExtractAPIKey(db database.Store, oauth *OAuth2Configs) func(http.Handler) h return func(next http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { var cookieValue string - cookie, err := r.Cookie(AuthCookie) + cookie, err := r.Cookie(SessionTokenKey) if err != nil { - cookieValue = r.URL.Query().Get(AuthCookie) + cookieValue = r.URL.Query().Get(SessionTokenKey) } else { cookieValue = cookie.Value } if cookieValue == "" { httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{ - Message: fmt.Sprintf("%q cookie or query parameter must be provided", AuthCookie), + Message: fmt.Sprintf("%q cookie or query parameter must be provided", SessionTokenKey), }) return } @@ -60,7 +60,7 @@ func ExtractAPIKey(db database.Store, oauth *OAuth2Configs) func(http.Handler) h // APIKeys are formatted: ID-SECRET if len(parts) != 2 { httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{ - Message: fmt.Sprintf("invalid %q cookie api key format", AuthCookie), + Message: fmt.Sprintf("invalid %q cookie api key format", SessionTokenKey), }) return } @@ -69,13 +69,13 @@ func ExtractAPIKey(db database.Store, oauth *OAuth2Configs) func(http.Handler) h // Ensuring key lengths are valid. if len(keyID) != 10 { httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{ - Message: fmt.Sprintf("invalid %q cookie api key id", AuthCookie), + Message: fmt.Sprintf("invalid %q cookie api key id", SessionTokenKey), }) return } if len(keySecret) != 22 { httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{ - Message: fmt.Sprintf("invalid %q cookie api key secret", AuthCookie), + Message: fmt.Sprintf("invalid %q cookie api key secret", SessionTokenKey), }) return } diff --git a/coderd/httpmw/apikey_test.go b/coderd/httpmw/apikey_test.go index 0c8d8d396e55b..3be2877426134 100644 --- a/coderd/httpmw/apikey_test.go +++ b/coderd/httpmw/apikey_test.go @@ -56,7 +56,7 @@ func TestAPIKey(t *testing.T) { rw = httptest.NewRecorder() ) r.AddCookie(&http.Cookie{ - Name: httpmw.AuthCookie, + Name: httpmw.SessionTokenKey, Value: "test-wow-hello", }) @@ -74,7 +74,7 @@ func TestAPIKey(t *testing.T) { rw = httptest.NewRecorder() ) r.AddCookie(&http.Cookie{ - Name: httpmw.AuthCookie, + Name: httpmw.SessionTokenKey, Value: "test-wow", }) @@ -92,7 +92,7 @@ func TestAPIKey(t *testing.T) { rw = httptest.NewRecorder() ) r.AddCookie(&http.Cookie{ - Name: httpmw.AuthCookie, + Name: httpmw.SessionTokenKey, Value: "testtestid-wow", }) @@ -111,7 +111,7 @@ func TestAPIKey(t *testing.T) { rw = httptest.NewRecorder() ) r.AddCookie(&http.Cookie{ - Name: httpmw.AuthCookie, + Name: httpmw.SessionTokenKey, Value: fmt.Sprintf("%s-%s", id, secret), }) @@ -130,7 +130,7 @@ func TestAPIKey(t *testing.T) { rw = httptest.NewRecorder() ) r.AddCookie(&http.Cookie{ - Name: httpmw.AuthCookie, + Name: httpmw.SessionTokenKey, Value: fmt.Sprintf("%s-%s", id, secret), }) @@ -157,7 +157,7 @@ func TestAPIKey(t *testing.T) { rw = httptest.NewRecorder() ) r.AddCookie(&http.Cookie{ - Name: httpmw.AuthCookie, + Name: httpmw.SessionTokenKey, Value: fmt.Sprintf("%s-%s", id, secret), }) @@ -182,7 +182,7 @@ func TestAPIKey(t *testing.T) { rw = httptest.NewRecorder() ) r.AddCookie(&http.Cookie{ - Name: httpmw.AuthCookie, + Name: httpmw.SessionTokenKey, Value: fmt.Sprintf("%s-%s", id, secret), }) @@ -209,6 +209,37 @@ func TestAPIKey(t *testing.T) { require.Equal(t, sentAPIKey.ExpiresAt, gotAPIKey.ExpiresAt) }) + t.Run("QueryParameter", func(t *testing.T) { + t.Parallel() + var ( + db = databasefake.New() + id, secret = randomAPIKeyParts() + hashed = sha256.Sum256([]byte(secret)) + r = httptest.NewRequest("GET", "/", nil) + rw = httptest.NewRecorder() + ) + q := r.URL.Query() + q.Add(httpmw.SessionTokenKey, fmt.Sprintf("%s-%s", id, secret)) + r.URL.RawQuery = q.Encode() + + _, err := db.InsertAPIKey(r.Context(), database.InsertAPIKeyParams{ + ID: id, + HashedSecret: hashed[:], + ExpiresAt: database.Now().AddDate(0, 0, 1), + }) + require.NoError(t, err) + httpmw.ExtractAPIKey(db, nil)(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + // Checks that it exists on the context! + _ = httpmw.APIKey(r) + httpapi.Write(rw, http.StatusOK, httpapi.Response{ + Message: "it worked!", + }) + })).ServeHTTP(rw, r) + res := rw.Result() + defer res.Body.Close() + require.Equal(t, http.StatusOK, res.StatusCode) + }) + t.Run("ValidUpdateLastUsed", func(t *testing.T) { t.Parallel() var ( @@ -219,7 +250,7 @@ func TestAPIKey(t *testing.T) { rw = httptest.NewRecorder() ) r.AddCookie(&http.Cookie{ - Name: httpmw.AuthCookie, + Name: httpmw.SessionTokenKey, Value: fmt.Sprintf("%s-%s", id, secret), }) @@ -252,7 +283,7 @@ func TestAPIKey(t *testing.T) { rw = httptest.NewRecorder() ) r.AddCookie(&http.Cookie{ - Name: httpmw.AuthCookie, + Name: httpmw.SessionTokenKey, Value: fmt.Sprintf("%s-%s", id, secret), }) @@ -285,7 +316,7 @@ func TestAPIKey(t *testing.T) { rw = httptest.NewRecorder() ) r.AddCookie(&http.Cookie{ - Name: httpmw.AuthCookie, + Name: httpmw.SessionTokenKey, Value: fmt.Sprintf("%s-%s", id, secret), }) @@ -319,7 +350,7 @@ func TestAPIKey(t *testing.T) { rw = httptest.NewRecorder() ) r.AddCookie(&http.Cookie{ - Name: httpmw.AuthCookie, + Name: httpmw.SessionTokenKey, Value: fmt.Sprintf("%s-%s", id, secret), }) diff --git a/coderd/httpmw/authorize_test.go b/coderd/httpmw/authorize_test.go index 59ee50c80d9be..0e2466da403cd 100644 --- a/coderd/httpmw/authorize_test.go +++ b/coderd/httpmw/authorize_test.go @@ -94,7 +94,7 @@ func TestExtractUserRoles(t *testing.T) { req := httptest.NewRequest("GET", "/", nil) req.AddCookie(&http.Cookie{ - Name: httpmw.AuthCookie, + Name: httpmw.SessionTokenKey, Value: token, }) diff --git a/coderd/httpmw/organizationparam_test.go b/coderd/httpmw/organizationparam_test.go index b062e63bc3819..2b3a01cc6aaa5 100644 --- a/coderd/httpmw/organizationparam_test.go +++ b/coderd/httpmw/organizationparam_test.go @@ -29,7 +29,7 @@ func TestOrganizationParam(t *testing.T) { hashed = sha256.Sum256([]byte(secret)) ) r.AddCookie(&http.Cookie{ - Name: httpmw.AuthCookie, + Name: httpmw.SessionTokenKey, Value: fmt.Sprintf("%s-%s", id, secret), }) diff --git a/coderd/httpmw/templateparam_test.go b/coderd/httpmw/templateparam_test.go index b4db9925391c3..201961ba26c54 100644 --- a/coderd/httpmw/templateparam_test.go +++ b/coderd/httpmw/templateparam_test.go @@ -29,7 +29,7 @@ func TestTemplateParam(t *testing.T) { ) r := httptest.NewRequest("GET", "/", nil) r.AddCookie(&http.Cookie{ - Name: httpmw.AuthCookie, + Name: httpmw.SessionTokenKey, Value: fmt.Sprintf("%s-%s", id, secret), }) diff --git a/coderd/httpmw/templateversionparam_test.go b/coderd/httpmw/templateversionparam_test.go index f168661d8bde8..d4487b183b788 100644 --- a/coderd/httpmw/templateversionparam_test.go +++ b/coderd/httpmw/templateversionparam_test.go @@ -29,7 +29,7 @@ func TestTemplateVersionParam(t *testing.T) { ) r := httptest.NewRequest("GET", "/", nil) r.AddCookie(&http.Cookie{ - Name: httpmw.AuthCookie, + Name: httpmw.SessionTokenKey, Value: fmt.Sprintf("%s-%s", id, secret), }) diff --git a/coderd/httpmw/userparam_test.go b/coderd/httpmw/userparam_test.go index 48e1da72e2142..d7a467d65940c 100644 --- a/coderd/httpmw/userparam_test.go +++ b/coderd/httpmw/userparam_test.go @@ -29,7 +29,7 @@ func TestUserParam(t *testing.T) { rw = httptest.NewRecorder() ) r.AddCookie(&http.Cookie{ - Name: httpmw.AuthCookie, + Name: httpmw.SessionTokenKey, Value: fmt.Sprintf("%s-%s", id, secret), }) diff --git a/coderd/httpmw/workspaceagent.go b/coderd/httpmw/workspaceagent.go index 05e7fe213c242..8bcf10f086285 100644 --- a/coderd/httpmw/workspaceagent.go +++ b/coderd/httpmw/workspaceagent.go @@ -28,10 +28,10 @@ func WorkspaceAgent(r *http.Request) database.WorkspaceAgent { func ExtractWorkspaceAgent(db database.Store) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - cookie, err := r.Cookie(AuthCookie) + cookie, err := r.Cookie(SessionTokenKey) if err != nil { httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{ - Message: fmt.Sprintf("%q cookie must be provided", AuthCookie), + Message: fmt.Sprintf("%q cookie must be provided", SessionTokenKey), }) return } diff --git a/coderd/httpmw/workspaceagent_test.go b/coderd/httpmw/workspaceagent_test.go index 650e68436b836..0661183abffcf 100644 --- a/coderd/httpmw/workspaceagent_test.go +++ b/coderd/httpmw/workspaceagent_test.go @@ -22,7 +22,7 @@ func TestWorkspaceAgent(t *testing.T) { token := uuid.New() r := httptest.NewRequest("GET", "/", nil) r.AddCookie(&http.Cookie{ - Name: httpmw.AuthCookie, + Name: httpmw.SessionTokenKey, Value: token.String(), }) return r, token diff --git a/coderd/httpmw/workspaceagentparam_test.go b/coderd/httpmw/workspaceagentparam_test.go index c7f931438901d..c985234824458 100644 --- a/coderd/httpmw/workspaceagentparam_test.go +++ b/coderd/httpmw/workspaceagentparam_test.go @@ -29,7 +29,7 @@ func TestWorkspaceAgentParam(t *testing.T) { ) r := httptest.NewRequest("GET", "/", nil) r.AddCookie(&http.Cookie{ - Name: httpmw.AuthCookie, + Name: httpmw.SessionTokenKey, Value: fmt.Sprintf("%s-%s", id, secret), }) diff --git a/coderd/httpmw/workspacebuildparam_test.go b/coderd/httpmw/workspacebuildparam_test.go index 0e72e02fcc9b9..7ed74e274cc9b 100644 --- a/coderd/httpmw/workspacebuildparam_test.go +++ b/coderd/httpmw/workspacebuildparam_test.go @@ -29,7 +29,7 @@ func TestWorkspaceBuildParam(t *testing.T) { ) r := httptest.NewRequest("GET", "/", nil) r.AddCookie(&http.Cookie{ - Name: httpmw.AuthCookie, + Name: httpmw.SessionTokenKey, Value: fmt.Sprintf("%s-%s", id, secret), }) diff --git a/coderd/httpmw/workspaceparam_test.go b/coderd/httpmw/workspaceparam_test.go index 2f1f522b5a211..52731dc10f1cb 100644 --- a/coderd/httpmw/workspaceparam_test.go +++ b/coderd/httpmw/workspaceparam_test.go @@ -29,7 +29,7 @@ func TestWorkspaceParam(t *testing.T) { ) r := httptest.NewRequest("GET", "/", nil) r.AddCookie(&http.Cookie{ - Name: httpmw.AuthCookie, + Name: httpmw.SessionTokenKey, Value: fmt.Sprintf("%s-%s", id, secret), }) diff --git a/coderd/users.go b/coderd/users.go index fbbbde5e250c1..92e2135b2fd37 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -690,7 +690,7 @@ func (*api) postLogout(rw http.ResponseWriter, _ *http.Request) { cookie := &http.Cookie{ // MaxAge < 0 means to delete the cookie now MaxAge: -1, - Name: httpmw.AuthCookie, + Name: httpmw.SessionTokenKey, Path: "/", } @@ -748,7 +748,7 @@ func (api *api) createAPIKey(rw http.ResponseWriter, r *http.Request, params dat // This format is consumed by the APIKey middleware. sessionToken := fmt.Sprintf("%s-%s", keyID, keySecret) http.SetCookie(rw, &http.Cookie{ - Name: httpmw.AuthCookie, + Name: httpmw.SessionTokenKey, Value: sessionToken, Path: "/", HttpOnly: true, diff --git a/coderd/users_test.go b/coderd/users_test.go index 9c2846ed96cbd..e0e1033819526 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -122,7 +122,7 @@ func TestPostLogout(t *testing.T) { cookies := response.Cookies() require.Len(t, cookies, 1, "Exactly one cookie should be returned") - require.Equal(t, cookies[0].Name, httpmw.AuthCookie, "Cookie should be the auth cookie") + require.Equal(t, cookies[0].Name, httpmw.SessionTokenKey, "Cookie should be the auth cookie") require.Equal(t, cookies[0].MaxAge, -1, "Cookie should be set to delete") }) } diff --git a/codersdk/client.go b/codersdk/client.go index f1fdfce9622f8..39d08fa758e2a 100644 --- a/codersdk/client.go +++ b/codersdk/client.go @@ -64,7 +64,7 @@ func (c *Client) Request(ctx context.Context, method, path string, body interfac return nil, xerrors.Errorf("create request: %w", err) } req.AddCookie(&http.Cookie{ - Name: httpmw.AuthCookie, + Name: httpmw.SessionTokenKey, Value: c.SessionToken, }) if body != nil { @@ -99,7 +99,7 @@ func (c *Client) websocket(ctx context.Context, path string) (*websocket.Conn, e } apiURL.Path = path q := apiURL.Query() - q.Add(httpmw.AuthCookie, c.SessionToken) + q.Add(httpmw.SessionTokenKey, c.SessionToken) apiURL.RawQuery = q.Encode() //nolint:bodyclose diff --git a/codersdk/workspaceagents.go b/codersdk/workspaceagents.go index d64b42bc5faaa..c634b1de7ea2a 100644 --- a/codersdk/workspaceagents.go +++ b/codersdk/workspaceagents.go @@ -188,7 +188,7 @@ func (c *Client) ListenWorkspaceAgent(ctx context.Context, logger slog.Logger) ( return agent.Metadata{}, nil, xerrors.Errorf("create cookie jar: %w", err) } jar.SetCookies(serverURL, []*http.Cookie{{ - Name: httpmw.AuthCookie, + Name: httpmw.SessionTokenKey, Value: c.SessionToken, }}) httpClient := &http.Client{ @@ -263,7 +263,7 @@ func (c *Client) DialWorkspaceAgent(ctx context.Context, agentID uuid.UUID, opti return nil, xerrors.Errorf("create cookie jar: %w", err) } jar.SetCookies(serverURL, []*http.Cookie{{ - Name: httpmw.AuthCookie, + Name: httpmw.SessionTokenKey, Value: c.SessionToken, }}) httpClient := &http.Client{ @@ -351,7 +351,7 @@ func (c *Client) WorkspaceAgentReconnectingPTY(ctx context.Context, agentID, rec return nil, xerrors.Errorf("create cookie jar: %w", err) } jar.SetCookies(serverURL, []*http.Cookie{{ - Name: httpmw.AuthCookie, + Name: httpmw.SessionTokenKey, Value: c.SessionToken, }}) httpClient := &http.Client{ From 078c711308e2b1b0086f6cfe6cd8b90b4b767424 Mon Sep 17 00:00:00 2001 From: Garrett Date: Tue, 17 May 2022 20:40:15 +0000 Subject: [PATCH 07/13] fix auth test --- coderd/coderd_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/coderd/coderd_test.go b/coderd/coderd_test.go index cc34d89ed6bee..de478615d2c12 100644 --- a/coderd/coderd_test.go +++ b/coderd/coderd_test.go @@ -128,6 +128,7 @@ func TestAuthorizeAllEndpoints(t *testing.T) { "PUT:/api/v2/workspaces/{workspace}/autostop": {NoAuthorize: true}, "GET:/api/v2/workspaces/{workspace}/builds": {NoAuthorize: true}, "POST:/api/v2/workspaces/{workspace}/builds": {NoAuthorize: true}, + "POST:/api/v2/workspaces/{workspace}/watch": {NoAuthorize: true}, "POST:/api/v2/files": {NoAuthorize: true}, "GET:/api/v2/files/{hash}": {NoAuthorize: true}, From 184cd47c6e1d9a293bbc7f34d5adb4037c187990 Mon Sep 17 00:00:00 2001 From: Garrett Date: Tue, 17 May 2022 20:43:41 +0000 Subject: [PATCH 08/13] fix lint --- coderd/workspaces.go | 3 ++- codersdk/client.go | 6 +++--- codersdk/workspaces.go | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 70379c4afe31c..c5b5669e49889 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -9,7 +9,6 @@ import ( "net/http" "time" - "cdr.dev/slog" "github.com/go-chi/chi/v5" "github.com/google/uuid" "github.com/moby/moby/pkg/namesgenerator" @@ -18,6 +17,8 @@ import ( "nhooyr.io/websocket" "nhooyr.io/websocket/wsjson" + "cdr.dev/slog" + "github.com/coder/coder/coderd/autobuild/schedule" "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/httpapi" diff --git a/codersdk/client.go b/codersdk/client.go index 39d08fa758e2a..71786de4f0cf4 100644 --- a/codersdk/client.go +++ b/codersdk/client.go @@ -81,9 +81,9 @@ func (c *Client) Request(ctx context.Context, method, path string, body interfac return resp, err } -// websocket opens a websocket connection on that path provided. -// The caller is responsible for closing the websocket.Conn. -func (c *Client) websocket(ctx context.Context, path string) (*websocket.Conn, error) { +// dialWebsocket opens a dialWebsocket connection on that path provided. +// The caller is responsible for closing the dialWebsocket.Conn. +func (c *Client) dialWebsocket(ctx context.Context, path string) (*websocket.Conn, error) { serverURL, err := c.URL.Parse(path) if err != nil { return nil, xerrors.Errorf("parse path: %w", err) diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index 31ed2a8dea67f..923a1e423f3be 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -124,7 +124,7 @@ func (w *WorkspaceWatcher) Close() error { } func (c *Client) WatchWorkspace(ctx context.Context, id uuid.UUID) (*WorkspaceWatcher, error) { - conn, err := c.websocket(ctx, fmt.Sprintf("/api/v2/workspaces/%s/watch", id)) + conn, err := c.dialWebsocket(ctx, fmt.Sprintf("/api/v2/workspaces/%s/watch", id)) if err != nil { return nil, err } From 509beef1c4a6fdc36784b154f90d6b22b8f3dc39 Mon Sep 17 00:00:00 2001 From: Garrett Date: Tue, 17 May 2022 20:45:06 +0000 Subject: [PATCH 09/13] fix auth test --- coderd/coderd_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/coderd_test.go b/coderd/coderd_test.go index de478615d2c12..30c6ad44aae01 100644 --- a/coderd/coderd_test.go +++ b/coderd/coderd_test.go @@ -128,7 +128,7 @@ func TestAuthorizeAllEndpoints(t *testing.T) { "PUT:/api/v2/workspaces/{workspace}/autostop": {NoAuthorize: true}, "GET:/api/v2/workspaces/{workspace}/builds": {NoAuthorize: true}, "POST:/api/v2/workspaces/{workspace}/builds": {NoAuthorize: true}, - "POST:/api/v2/workspaces/{workspace}/watch": {NoAuthorize: true}, + "CONNECT:/api/v2/workspaces/{workspace}/watch": {NoAuthorize: true}, "POST:/api/v2/files": {NoAuthorize: true}, "GET:/api/v2/files/{hash}": {NoAuthorize: true}, From be27df51402dad44431dfd6eedf8607957ed7d5e Mon Sep 17 00:00:00 2001 From: Garrett Date: Tue, 17 May 2022 20:57:54 +0000 Subject: [PATCH 10/13] fix auth again --- coderd/coderd.go | 2 +- coderd/coderd_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/coderd/coderd.go b/coderd/coderd.go index a639982248596..743bc5a0cbe9c 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -311,7 +311,7 @@ func New(options *Options) (http.Handler, func()) { r.Put("/", api.putWorkspaceAutostop) }) }) - r.HandleFunc("/watch", api.watchWorkspace) + r.Get("/watch", api.watchWorkspace) }) r.Route("/workspacebuilds/{workspacebuild}", func(r chi.Router) { r.Use( diff --git a/coderd/coderd_test.go b/coderd/coderd_test.go index 30c6ad44aae01..23b82b0d374c6 100644 --- a/coderd/coderd_test.go +++ b/coderd/coderd_test.go @@ -128,7 +128,7 @@ func TestAuthorizeAllEndpoints(t *testing.T) { "PUT:/api/v2/workspaces/{workspace}/autostop": {NoAuthorize: true}, "GET:/api/v2/workspaces/{workspace}/builds": {NoAuthorize: true}, "POST:/api/v2/workspaces/{workspace}/builds": {NoAuthorize: true}, - "CONNECT:/api/v2/workspaces/{workspace}/watch": {NoAuthorize: true}, + "GET:/api/v2/workspaces/{workspace}/watch": {NoAuthorize: true}, "POST:/api/v2/files": {NoAuthorize: true}, "GET:/api/v2/files/{hash}": {NoAuthorize: true}, From f029650d30328d483651bd2b63b858cf6d7ba5c5 Mon Sep 17 00:00:00 2001 From: Garrett Date: Wed, 18 May 2022 16:12:12 +0000 Subject: [PATCH 11/13] move to channel over WorkspaceWatcher --- coderd/workspaces_test.go | 17 +++++++------ codersdk/workspaces.go | 50 ++++++++++++++++++--------------------- 2 files changed, 31 insertions(+), 36 deletions(-) diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 979cb784d2750..6658aa8481940 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -634,15 +634,14 @@ func TestWorkspaceWatcher(t *testing.T) { w, err := client.Workspace(context.Background(), workspace.ID) require.NoError(t, err) - ww, err := client.WatchWorkspace(context.Background(), w.ID) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + wc, err := client.WatchWorkspace(ctx, w.ID) require.NoError(t, err) - defer ww.Close() - for i := 0; i < 5; i++ { - _, err := ww.Read(context.Background()) - require.NoError(t, err) + for i := 0; i < 3; i++ { + _, more := <-wc + require.True(t, more) } - err = ww.Close() - require.NoError(t, err) - _, err = ww.Read(context.Background()) - require.Error(t, err) + cancel() + require.EqualValues(t, codersdk.Workspace{}, <-wc) } diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index 923a1e423f3be..d43219e0f9d65 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -100,38 +100,34 @@ func (c *Client) WorkspaceBuildByName(ctx context.Context, workspace uuid.UUID, return workspaceBuild, json.NewDecoder(res.Body).Decode(&workspaceBuild) } -type WorkspaceWatcher struct { - conn *websocket.Conn -} - -func (w *WorkspaceWatcher) Read(ctx context.Context) (Workspace, error) { - var ws Workspace - err := wsjson.Read(ctx, w.conn, &ws) - if err != nil { - return ws, xerrors.Errorf("read workspace: %w", err) - } - - return ws, nil -} - -func (w *WorkspaceWatcher) Close() error { - err := w.conn.Close(websocket.StatusNormalClosure, "") - if err != nil { - return xerrors.Errorf("closing workspace watcher: %w", err) - } - - return nil -} - -func (c *Client) WatchWorkspace(ctx context.Context, id uuid.UUID) (*WorkspaceWatcher, error) { +func (c *Client) WatchWorkspace(ctx context.Context, id uuid.UUID) (<-chan Workspace, error) { conn, err := c.dialWebsocket(ctx, fmt.Sprintf("/api/v2/workspaces/%s/watch", id)) if err != nil { return nil, err } + wc := make(chan Workspace, 256) + + go func() { + defer close(wc) + defer conn.Close(websocket.StatusNormalClosure, "") + + for { + select { + case <-ctx.Done(): + return + default: + var ws Workspace + err := wsjson.Read(ctx, conn, &ws) + if err != nil { + conn.Close(websocket.StatusInternalError, "failed to read workspace") + return + } + wc <- ws + } + } + }() - return &WorkspaceWatcher{ - conn: conn, - }, nil + return wc, nil } // UpdateWorkspaceAutostartRequest is a request to update a workspace's autostart schedule. From 534bcdba192d5e4c22619fe251c195c92a50a986 Mon Sep 17 00:00:00 2001 From: Garrett Date: Wed, 18 May 2022 16:41:27 +0000 Subject: [PATCH 12/13] rebase and fix tests --- coderd/coderd.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/coderd.go b/coderd/coderd.go index 743bc5a0cbe9c..cc411ef71e255 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -310,8 +310,8 @@ func New(options *Options) (http.Handler, func()) { r.Route("/autostop", func(r chi.Router) { r.Put("/", api.putWorkspaceAutostop) }) + r.Get("/watch", api.watchWorkspace) }) - r.Get("/watch", api.watchWorkspace) }) r.Route("/workspacebuilds/{workspacebuild}", func(r chi.Router) { r.Use( From db49672a6baf08d3df3258250b3303751cedcdc3 Mon Sep 17 00:00:00 2001 From: Garrett Date: Wed, 18 May 2022 16:45:24 +0000 Subject: [PATCH 13/13] rebase and fix merge --- coderd/workspaces.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/workspaces.go b/coderd/workspaces.go index c5b5669e49889..1e567dfe77c40 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -589,7 +589,7 @@ func (api *api) watchWorkspace(rw http.ResponseWriter, r *http.Request) { }) return } - build, err := api.Database.GetWorkspaceBuildByWorkspaceIDWithoutAfter(r.Context(), workspace.ID) + build, err := api.Database.GetLatestWorkspaceBuildByWorkspaceID(r.Context(), workspace.ID) if err != nil { _ = wsjson.Write(ctx, c, httpapi.Response{ Message: fmt.Sprintf("get workspace build: %s", err),