diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 2ad130318dcbb..4ebc3112a188d 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -5177,6 +5177,39 @@ const docTemplate = `{ } }, "/workspaceproxies/{workspaceproxy}": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Enterprise" + ], + "summary": "Get workspace proxy", + "operationId": "get-workspace-proxy", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Proxy ID or name", + "name": "workspaceproxy", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.WorkspaceProxy" + } + } + } + }, "delete": { "security": [ { @@ -5209,6 +5242,51 @@ const docTemplate = `{ } } } + }, + "patch": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Enterprise" + ], + "summary": "Update workspace proxy", + "operationId": "update-workspace-proxy", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Proxy ID or name", + "name": "workspaceproxy", + "in": "path", + "required": true + }, + { + "description": "Update workspace proxy request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.PatchWorkspaceProxy" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.WorkspaceProxy" + } + } + } } }, "/workspaces": { @@ -8073,6 +8151,33 @@ const docTemplate = `{ } } }, + "codersdk.PatchWorkspaceProxy": { + "type": "object", + "required": [ + "display_name", + "icon", + "id", + "name" + ], + "properties": { + "display_name": { + "type": "string" + }, + "icon": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "regenerate_token": { + "type": "boolean" + } + } + }, "codersdk.PprofConfig": { "type": "object", "properties": { @@ -9855,6 +9960,9 @@ const docTemplate = `{ "deleted": { "type": "boolean" }, + "display_name": { + "type": "string" + }, "icon": { "type": "string" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index e2a593c1a415c..cdf0f5972a106 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -4553,6 +4553,35 @@ } }, "/workspaceproxies/{workspaceproxy}": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Enterprise"], + "summary": "Get workspace proxy", + "operationId": "get-workspace-proxy", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Proxy ID or name", + "name": "workspaceproxy", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.WorkspaceProxy" + } + } + } + }, "delete": { "security": [ { @@ -4581,6 +4610,45 @@ } } } + }, + "patch": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Enterprise"], + "summary": "Update workspace proxy", + "operationId": "update-workspace-proxy", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Proxy ID or name", + "name": "workspaceproxy", + "in": "path", + "required": true + }, + { + "description": "Update workspace proxy request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.PatchWorkspaceProxy" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.WorkspaceProxy" + } + } + } } }, "/workspaces": { @@ -7215,6 +7283,28 @@ } } }, + "codersdk.PatchWorkspaceProxy": { + "type": "object", + "required": ["display_name", "icon", "id", "name"], + "properties": { + "display_name": { + "type": "string" + }, + "icon": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "regenerate_token": { + "type": "boolean" + } + } + }, "codersdk.PprofConfig": { "type": "object", "properties": { @@ -8892,6 +8982,9 @@ "deleted": { "type": "boolean" }, + "display_name": { + "type": "string" + }, "icon": { "type": "string" }, diff --git a/coderd/database/dbauthz/querier.go b/coderd/database/dbauthz/querier.go index 54207fd39085e..1f3a5b81663b9 100644 --- a/coderd/database/dbauthz/querier.go +++ b/coderd/database/dbauthz/querier.go @@ -1705,6 +1705,13 @@ func (q *querier) InsertWorkspaceProxy(ctx context.Context, arg database.InsertW return insert(q.log, q.auth, rbac.ResourceWorkspaceProxy, q.db.InsertWorkspaceProxy)(ctx, arg) } +func (q *querier) UpdateWorkspaceProxy(ctx context.Context, arg database.UpdateWorkspaceProxyParams) (database.WorkspaceProxy, error) { + fetch := func(ctx context.Context, arg database.UpdateWorkspaceProxyParams) (database.WorkspaceProxy, error) { + return q.db.GetWorkspaceProxyByID(ctx, arg.ID) + } + return updateWithReturn(q.log, q.auth, fetch, q.db.UpdateWorkspaceProxy)(ctx, arg) +} + func (q *querier) RegisterWorkspaceProxy(ctx context.Context, arg database.RegisterWorkspaceProxyParams) (database.WorkspaceProxy, error) { fetch := func(ctx context.Context, arg database.RegisterWorkspaceProxyParams) (database.WorkspaceProxy, error) { return q.db.GetWorkspaceProxyByID(ctx, arg.ID) diff --git a/coderd/database/dbfake/databasefake.go b/coderd/database/dbfake/databasefake.go index f254ee2668f12..52d0684cecbb6 100644 --- a/coderd/database/dbfake/databasefake.go +++ b/coderd/database/dbfake/databasefake.go @@ -5233,6 +5233,31 @@ func (q *fakeQuerier) RegisterWorkspaceProxy(_ context.Context, arg database.Reg return database.WorkspaceProxy{}, sql.ErrNoRows } +func (q *fakeQuerier) UpdateWorkspaceProxy(_ context.Context, arg database.UpdateWorkspaceProxyParams) (database.WorkspaceProxy, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + + for _, p := range q.workspaceProxies { + if p.Name == arg.Name && p.ID != arg.ID { + return database.WorkspaceProxy{}, errDuplicateKey + } + } + + for i, p := range q.workspaceProxies { + if p.ID == arg.ID { + p.Name = arg.Name + p.DisplayName = arg.DisplayName + p.Icon = arg.Icon + if len(p.TokenHashedSecret) > 0 { + p.TokenHashedSecret = arg.TokenHashedSecret + } + q.workspaceProxies[i] = p + return p, nil + } + } + return database.WorkspaceProxy{}, sql.ErrNoRows +} + func (q *fakeQuerier) UpdateWorkspaceProxyDeleted(_ context.Context, arg database.UpdateWorkspaceProxyDeletedParams) error { q.mutex.Lock() defer q.mutex.Unlock() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 01b79c0ae1f0f..e30207c7ba44e 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -255,6 +255,8 @@ type sqlcQuerier interface { UpdateWorkspaceBuildCostByID(ctx context.Context, arg UpdateWorkspaceBuildCostByIDParams) (WorkspaceBuild, error) UpdateWorkspaceDeletedByID(ctx context.Context, arg UpdateWorkspaceDeletedByIDParams) error UpdateWorkspaceLastUsedAt(ctx context.Context, arg UpdateWorkspaceLastUsedAtParams) error + // This allows editing the properties of a workspace proxy. + UpdateWorkspaceProxy(ctx context.Context, arg UpdateWorkspaceProxyParams) (WorkspaceProxy, error) UpdateWorkspaceProxyDeleted(ctx context.Context, arg UpdateWorkspaceProxyDeletedParams) error UpdateWorkspaceTTL(ctx context.Context, arg UpdateWorkspaceTTLParams) error UpdateWorkspaceTTLToBeWithinTemplateMax(ctx context.Context, arg UpdateWorkspaceTTLToBeWithinTemplateMaxParams) error diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 5b21b8cc7304b..23524b4386cc2 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -3077,6 +3077,60 @@ func (q *sqlQuerier) RegisterWorkspaceProxy(ctx context.Context, arg RegisterWor return i, err } +const updateWorkspaceProxy = `-- name: UpdateWorkspaceProxy :one +UPDATE + workspace_proxies +SET + -- These values should always be provided. + name = $1, + display_name = $2, + icon = $3, + -- Only update the token if a new one is provided. + -- So this is an optional field. + token_hashed_secret = CASE + WHEN length($4 :: bytea) > 0 THEN $4 :: bytea + ELSE workspace_proxies.token_hashed_secret + END, + -- Always update this timestamp. + updated_at = Now() +WHERE + id = $5 +RETURNING id, name, display_name, icon, url, wildcard_hostname, created_at, updated_at, deleted, token_hashed_secret +` + +type UpdateWorkspaceProxyParams struct { + Name string `db:"name" json:"name"` + DisplayName string `db:"display_name" json:"display_name"` + Icon string `db:"icon" json:"icon"` + TokenHashedSecret []byte `db:"token_hashed_secret" json:"token_hashed_secret"` + ID uuid.UUID `db:"id" json:"id"` +} + +// This allows editing the properties of a workspace proxy. +func (q *sqlQuerier) UpdateWorkspaceProxy(ctx context.Context, arg UpdateWorkspaceProxyParams) (WorkspaceProxy, error) { + row := q.db.QueryRowContext(ctx, updateWorkspaceProxy, + arg.Name, + arg.DisplayName, + arg.Icon, + arg.TokenHashedSecret, + arg.ID, + ) + var i WorkspaceProxy + err := row.Scan( + &i.ID, + &i.Name, + &i.DisplayName, + &i.Icon, + &i.Url, + &i.WildcardHostname, + &i.CreatedAt, + &i.UpdatedAt, + &i.Deleted, + &i.TokenHashedSecret, + ) + return i, err +} + const updateWorkspaceProxyDeleted = `-- name: UpdateWorkspaceProxyDeleted :exec UPDATE workspace_proxies diff --git a/coderd/database/queries/proxies.sql b/coderd/database/queries/proxies.sql index 722138938f1ae..c6be3333fb9c6 100644 --- a/coderd/database/queries/proxies.sql +++ b/coderd/database/queries/proxies.sql @@ -36,6 +36,28 @@ SET WHERE id = @id; +-- name: UpdateWorkspaceProxy :one +-- This allows editing the properties of a workspace proxy. +UPDATE + workspace_proxies +SET + -- These values should always be provided. + name = @name, + display_name = @display_name, + icon = @icon, + -- Only update the token if a new one is provided. + -- So this is an optional field. + token_hashed_secret = CASE + WHEN length(@token_hashed_secret :: bytea) > 0 THEN @token_hashed_secret :: bytea + ELSE workspace_proxies.token_hashed_secret + END, + -- Always update this timestamp. + updated_at = Now() +WHERE + id = @id +RETURNING * +; + -- name: GetWorkspaceProxyByID :one SELECT * @@ -57,6 +79,14 @@ WHERE LIMIT 1; +-- name: GetWorkspaceProxies :many +SELECT + * +FROM + workspace_proxies +WHERE + deleted = false; + -- Finds a workspace proxy that has an access URL or app hostname that matches -- the provided hostname. This is to check if a hostname matches any workspace -- proxy. @@ -94,11 +124,3 @@ WHERE ) LIMIT 1; - --- name: GetWorkspaceProxies :many -SELECT - * -FROM - workspace_proxies -WHERE - deleted = false; diff --git a/codersdk/workspaceproxy.go b/codersdk/workspaceproxy.go index 8c8e49e63aebc..95eea1b3a17a7 100644 --- a/codersdk/workspaceproxy.go +++ b/codersdk/workspaceproxy.go @@ -46,9 +46,10 @@ type ProxyHealthReport struct { } type WorkspaceProxy struct { - ID uuid.UUID `json:"id" format:"uuid" table:"id"` - Name string `json:"name" table:"name,default_sort"` - Icon string `json:"icon" table:"icon"` + ID uuid.UUID `json:"id" format:"uuid" table:"id"` + Name string `json:"name" table:"name,default_sort"` + DisplayName string `json:"display_name" table:"display_name"` + Icon string `json:"icon" table:"icon"` // Full url including scheme of the proxy api url: https://us.example.com URL string `json:"url" table:"url"` // WildcardHostname with the wildcard for subdomain based app hosting: *.us.example.com @@ -69,26 +70,26 @@ type CreateWorkspaceProxyRequest struct { Icon string `json:"icon"` } -type CreateWorkspaceProxyResponse struct { +type UpdateWorkspaceProxyResponse struct { Proxy WorkspaceProxy `json:"proxy" table:"proxy,recursive"` // The recursive table sort is not working very well. ProxyToken string `json:"proxy_token" table:"proxy token,default_sort"` } -func (c *Client) CreateWorkspaceProxy(ctx context.Context, req CreateWorkspaceProxyRequest) (CreateWorkspaceProxyResponse, error) { +func (c *Client) CreateWorkspaceProxy(ctx context.Context, req CreateWorkspaceProxyRequest) (UpdateWorkspaceProxyResponse, error) { res, err := c.Request(ctx, http.MethodPost, "/api/v2/workspaceproxies", req, ) if err != nil { - return CreateWorkspaceProxyResponse{}, xerrors.Errorf("make request: %w", err) + return UpdateWorkspaceProxyResponse{}, xerrors.Errorf("make request: %w", err) } defer res.Body.Close() if res.StatusCode != http.StatusCreated { - return CreateWorkspaceProxyResponse{}, ReadBodyAsError(res) + return UpdateWorkspaceProxyResponse{}, ReadBodyAsError(res) } - var resp CreateWorkspaceProxyResponse + var resp UpdateWorkspaceProxyResponse return resp, json.NewDecoder(res.Body).Decode(&resp) } @@ -110,6 +111,31 @@ func (c *Client) WorkspaceProxies(ctx context.Context) ([]WorkspaceProxy, error) return proxies, json.NewDecoder(res.Body).Decode(&proxies) } +type PatchWorkspaceProxy struct { + ID uuid.UUID `json:"id" format:"uuid" validate:"required"` + Name string `json:"name" validate:"required"` + DisplayName string `json:"display_name" validate:"required"` + Icon string `json:"icon" validate:"required"` + RegenerateToken bool `json:"regenerate_token"` +} + +func (c *Client) PatchWorkspaceProxy(ctx context.Context, req PatchWorkspaceProxy) (UpdateWorkspaceProxyResponse, error) { + res, err := c.Request(ctx, http.MethodPatch, + fmt.Sprintf("/api/v2/workspaceproxies/%s", req.ID.String()), + req, + ) + if err != nil { + return UpdateWorkspaceProxyResponse{}, xerrors.Errorf("make request: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return UpdateWorkspaceProxyResponse{}, ReadBodyAsError(res) + } + var resp UpdateWorkspaceProxyResponse + return resp, json.NewDecoder(res.Body).Decode(&resp) +} + func (c *Client) DeleteWorkspaceProxyByName(ctx context.Context, name string) error { res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/workspaceproxies/%s", name), @@ -131,6 +157,28 @@ func (c *Client) DeleteWorkspaceProxyByID(ctx context.Context, id uuid.UUID) err return c.DeleteWorkspaceProxyByName(ctx, id.String()) } +func (c *Client) WorkspaceProxyByName(ctx context.Context, name string) (WorkspaceProxy, error) { + res, err := c.Request(ctx, http.MethodGet, + fmt.Sprintf("/api/v2/workspaceproxies/%s", name), + nil, + ) + if err != nil { + return WorkspaceProxy{}, xerrors.Errorf("make request: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return WorkspaceProxy{}, ReadBodyAsError(res) + } + + var resp WorkspaceProxy + return resp, json.NewDecoder(res.Body).Decode(&resp) +} + +func (c *Client) WorkspaceProxyByID(ctx context.Context, id uuid.UUID) (WorkspaceProxy, error) { + return c.WorkspaceProxyByName(ctx, id.String()) +} + type RegionsResponse struct { Regions []Region `json:"regions"` } diff --git a/docs/api/enterprise.md b/docs/api/enterprise.md index fbee85b9970f1..95def717b7f3e 100644 --- a/docs/api/enterprise.md +++ b/docs/api/enterprise.md @@ -1182,6 +1182,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaceproxies \ { "created_at": "2019-08-24T14:15:22Z", "deleted": true, + "display_name": "string", "icon": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "name": "string", @@ -1215,6 +1216,7 @@ Status Code **200** | `[array item]` | array | false | | | | `» created_at` | string(date-time) | false | | | | `» deleted` | boolean | false | | | +| `» display_name` | string | false | | | | `» icon` | string | false | | | | `» id` | string(uuid) | false | | | | `» name` | string | false | | | @@ -1277,6 +1279,7 @@ curl -X POST http://coder-server:8080/api/v2/workspaceproxies \ { "created_at": "2019-08-24T14:15:22Z", "deleted": true, + "display_name": "string", "icon": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "name": "string", @@ -1302,6 +1305,59 @@ curl -X POST http://coder-server:8080/api/v2/workspaceproxies \ To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Get workspace proxy + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/workspaceproxies/{workspaceproxy} \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /workspaceproxies/{workspaceproxy}` + +### Parameters + +| Name | In | Type | Required | Description | +| ---------------- | ---- | ------------ | -------- | ---------------- | +| `workspaceproxy` | path | string(uuid) | true | Proxy ID or name | + +### Example responses + +> 200 Response + +```json +{ + "created_at": "2019-08-24T14:15:22Z", + "deleted": true, + "display_name": "string", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "name": "string", + "status": { + "checked_at": "2019-08-24T14:15:22Z", + "report": { + "errors": ["string"], + "warnings": ["string"] + }, + "status": "reachable" + }, + "updated_at": "2019-08-24T14:15:22Z", + "url": "string", + "wildcard_hostname": "string" +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | ------------------------------------------------------------ | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.WorkspaceProxy](schemas.md#codersdkworkspaceproxy) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Delete workspace proxy ### Code samples @@ -1345,3 +1401,70 @@ curl -X DELETE http://coder-server:8080/api/v2/workspaceproxies/{workspaceproxy} | 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.Response](schemas.md#codersdkresponse) | To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Update workspace proxy + +### Code samples + +```shell +# Example request using curl +curl -X PATCH http://coder-server:8080/api/v2/workspaceproxies/{workspaceproxy} \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`PATCH /workspaceproxies/{workspaceproxy}` + +> Body parameter + +```json +{ + "display_name": "string", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "name": "string", + "regenerate_token": true +} +``` + +### Parameters + +| Name | In | Type | Required | Description | +| ---------------- | ---- | ---------------------------------------------------------------------- | -------- | ------------------------------ | +| `workspaceproxy` | path | string(uuid) | true | Proxy ID or name | +| `body` | body | [codersdk.PatchWorkspaceProxy](schemas.md#codersdkpatchworkspaceproxy) | true | Update workspace proxy request | + +### Example responses + +> 200 Response + +```json +{ + "created_at": "2019-08-24T14:15:22Z", + "deleted": true, + "display_name": "string", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "name": "string", + "status": { + "checked_at": "2019-08-24T14:15:22Z", + "report": { + "errors": ["string"], + "warnings": ["string"] + }, + "status": "reachable" + }, + "updated_at": "2019-08-24T14:15:22Z", + "url": "string", + "wildcard_hostname": "string" +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | ------------------------------------------------------------ | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.WorkspaceProxy](schemas.md#codersdkworkspaceproxy) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 6a82b9a1c98ec..977e467c5b2ba 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -3212,6 +3212,28 @@ Parameter represents a set value for the scope. | ------ | ------ | -------- | ------------ | ----------- | | `name` | string | false | | | +## codersdk.PatchWorkspaceProxy + +```json +{ + "display_name": "string", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "name": "string", + "regenerate_token": true +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ------------------ | ------- | -------- | ------------ | ----------- | +| `display_name` | string | true | | | +| `icon` | string | true | | | +| `id` | string | true | | | +| `name` | string | true | | | +| `regenerate_token` | boolean | false | | | + ## codersdk.PprofConfig ```json @@ -5329,6 +5351,7 @@ Parameter represents a set value for the scope. { "created_at": "2019-08-24T14:15:22Z", "deleted": true, + "display_name": "string", "icon": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "name": "string", @@ -5352,6 +5375,7 @@ Parameter represents a set value for the scope. | ------------------- | -------------------------------------------------------------- | -------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `created_at` | string | false | | | | `deleted` | boolean | false | | | +| `display_name` | string | false | | | | `icon` | string | false | | | | `id` | string | false | | | | `name` | string | false | | | diff --git a/enterprise/cli/workspaceproxy.go b/enterprise/cli/workspaceproxy.go index ef4f57ab3c436..fac487dce8021 100644 --- a/enterprise/cli/workspaceproxy.go +++ b/enterprise/cli/workspaceproxy.go @@ -1,6 +1,7 @@ package cli import ( + "context" "fmt" "strings" @@ -26,64 +27,189 @@ func (r *RootCmd) workspaceProxy() *clibase.Cmd { r.createProxy(), r.deleteProxy(), r.listProxies(), + r.patchProxy(), + r.regenerateProxyToken(), }, } return cmd } -func (r *RootCmd) deleteProxy() *clibase.Cmd { +func (r *RootCmd) regenerateProxyToken() *clibase.Cmd { + formatter := newUpdateProxyResponseFormatter() client := new(codersdk.Client) cmd := &clibase.Cmd{ - Use: "delete ", - Short: "Delete a workspace proxy", + Use: "regenerate-token ", + Short: "Regenerate a workspace proxy authentication token. " + + "This will invalidate the existing authentication token.", Middleware: clibase.Chain( clibase.RequireNArgs(1), r.InitClient(client), ), Handler: func(inv *clibase.Invocation) error { ctx := inv.Context() - err := client.DeleteWorkspaceProxyByName(ctx, inv.Args[0]) + // This is cheeky, but you can also use a uuid string in + // 'DeleteWorkspaceProxyByName' and it will work. + proxy, err := client.WorkspaceProxyByName(ctx, inv.Args[0]) if err != nil { - return xerrors.Errorf("delete workspace proxy %q: %w", inv.Args[0], err) + return xerrors.Errorf("fetch workspace proxy %q: %w", inv.Args[0], err) } - _, _ = fmt.Fprintf(inv.Stdout, "Workspace proxy %q deleted successfully\n", inv.Args[0]) - return nil + // Only regenerate the token + updated, err := client.PatchWorkspaceProxy(ctx, codersdk.PatchWorkspaceProxy{ + ID: proxy.ID, + Name: proxy.Name, + DisplayName: proxy.DisplayName, + Icon: proxy.Icon, + RegenerateToken: true, + }) + if err != nil { + return xerrors.Errorf("update workspace proxy %q: %w", inv.Args[0], err) + } + + output, err := formatter.Format(ctx, updated) + if err != nil { + return err + } + _, err = fmt.Fprintln(inv.Stdout, output) + return err }, } + formatter.AttachOptions(&cmd.Options) return cmd } -func (r *RootCmd) createProxy() *clibase.Cmd { +func (r *RootCmd) patchProxy() *clibase.Cmd { var ( proxyName string displayName string proxyIcon string - onlyToken bool formatter = cliui.NewOutputFormatter( // Text formatter should be human readable. cliui.ChangeFormatterData(cliui.TextFormat(), func(data any) (any, error) { - response, ok := data.(codersdk.CreateWorkspaceProxyResponse) + response, ok := data.(codersdk.WorkspaceProxy) if !ok { return nil, xerrors.Errorf("unexpected type %T", data) } - return fmt.Sprintf("Workspace Proxy %q created successfully. Save this token, it will not be shown again."+ - "\nToken: %s", response.Proxy.Name, response.ProxyToken), nil + return fmt.Sprintf("Workspace Proxy %q updated successfully.", response.Name), nil }), cliui.JSONFormat(), // Table formatter expects a slice, make a slice of one. - cliui.ChangeFormatterData(cliui.TableFormat([]codersdk.CreateWorkspaceProxyResponse{}, []string{"proxy name", "proxy url", "proxy token"}), + cliui.ChangeFormatterData(cliui.TableFormat([]codersdk.WorkspaceProxy{}, []string{"proxy name", "proxy url"}), func(data any) (any, error) { - response, ok := data.(codersdk.CreateWorkspaceProxyResponse) + response, ok := data.(codersdk.WorkspaceProxy) if !ok { return nil, xerrors.Errorf("unexpected type %T", data) } - return []codersdk.CreateWorkspaceProxyResponse{response}, nil + return []codersdk.WorkspaceProxy{response}, nil }), ) ) + client := new(codersdk.Client) + cmd := &clibase.Cmd{ + Use: "edit ", + Short: "Edit a workspace proxy", + Middleware: clibase.Chain( + clibase.RequireNArgs(1), + r.InitClient(client), + ), + Handler: func(inv *clibase.Invocation) error { + ctx := inv.Context() + if proxyIcon == "" && displayName == "" && proxyName == "" { + return xerrors.Errorf("specify at least one field to update") + } + + // This is cheeky, but you can also use a uuid string in + // 'DeleteWorkspaceProxyByName' and it will work. + proxy, err := client.WorkspaceProxyByName(ctx, inv.Args[0]) + if err != nil { + return xerrors.Errorf("fetch workspace proxy %q: %w", inv.Args[0], err) + } + + // Use the existing values if the user didn't specify them. + if proxyName == "" { + proxyName = proxy.Name + } + if displayName == "" { + displayName = proxy.DisplayName + } + if proxyIcon == "" { + proxyIcon = proxy.Icon + } + + updated, err := client.PatchWorkspaceProxy(ctx, codersdk.PatchWorkspaceProxy{ + ID: proxy.ID, + Name: proxyName, + DisplayName: displayName, + Icon: proxyIcon, + }) + if err != nil { + return xerrors.Errorf("update workspace proxy %q: %w", inv.Args[0], err) + } + + output, err := formatter.Format(ctx, updated.Proxy) + if err != nil { + return xerrors.Errorf("format response: %w", err) + } + _, err = fmt.Fprintln(inv.Stdout, output) + return err + }, + } + + formatter.AttachOptions(&cmd.Options) + cmd.Options.Add( + clibase.Option{ + Flag: "name", + Description: "(Optional) Name of the proxy. This is used to identify the proxy.", + Value: clibase.StringOf(&proxyName), + }, + clibase.Option{ + Flag: "display-name", + Description: "(Optional) Display of the proxy. A more human friendly name to be displayed.", + Value: clibase.StringOf(&displayName), + }, + clibase.Option{ + Flag: "icon", + Description: "(Optional) Display icon of the proxy.", + Value: clibase.StringOf(&proxyIcon), + }, + ) + + return cmd +} + +func (r *RootCmd) deleteProxy() *clibase.Cmd { + client := new(codersdk.Client) + cmd := &clibase.Cmd{ + Use: "delete ", + Short: "Delete a workspace proxy", + Middleware: clibase.Chain( + clibase.RequireNArgs(1), + r.InitClient(client), + ), + Handler: func(inv *clibase.Invocation) error { + ctx := inv.Context() + err := client.DeleteWorkspaceProxyByName(ctx, inv.Args[0]) + if err != nil { + return xerrors.Errorf("delete workspace proxy %q: %w", inv.Args[0], err) + } + + _, _ = fmt.Fprintf(inv.Stdout, "Workspace proxy %q deleted successfully\n", inv.Args[0]) + return nil + }, + } + + return cmd +} + +func (r *RootCmd) createProxy() *clibase.Cmd { + var ( + proxyName string + displayName string + proxyIcon string + formatter = newUpdateProxyResponseFormatter() + ) client := new(codersdk.Client) cmd := &clibase.Cmd{ @@ -108,16 +234,10 @@ func (r *RootCmd) createProxy() *clibase.Cmd { return xerrors.Errorf("create workspace proxy: %w", err) } - var output string - if onlyToken { - output = resp.ProxyToken - } else { - output, err = formatter.Format(ctx, resp) - if err != nil { - return err - } + output, err := formatter.Format(ctx, resp) + if err != nil { + return err } - _, err = fmt.Fprintln(inv.Stdout, output) return err }, @@ -140,11 +260,6 @@ func (r *RootCmd) createProxy() *clibase.Cmd { Description: "Display icon of the proxy.", Value: clibase.StringOf(&proxyIcon), }, - clibase.Option{ - Flag: "only-token", - Description: "Only print the token. This is useful for scripting.", - Value: clibase.BoolOf(&onlyToken), - }, ) return cmd } @@ -205,3 +320,56 @@ func (r *RootCmd) listProxies() *clibase.Cmd { formatter.AttachOptions(&cmd.Options) return cmd } + +// updateProxyResponseFormatter is used for both create and regenerate proxy commands. +type updateProxyResponseFormatter struct { + onlyToken bool + formatter *cliui.OutputFormatter +} + +func (f *updateProxyResponseFormatter) Format(ctx context.Context, data codersdk.UpdateWorkspaceProxyResponse) (string, error) { + if f.onlyToken { + return data.ProxyToken, nil + } + return f.formatter.Format(ctx, data) +} + +func (f *updateProxyResponseFormatter) AttachOptions(opts *clibase.OptionSet) { + opts.Add( + clibase.Option{ + Flag: "only-token", + Description: "Only print the token. This is useful for scripting.", + Value: clibase.BoolOf(&f.onlyToken), + }, + ) + f.formatter.AttachOptions(opts) +} + +func newUpdateProxyResponseFormatter() *updateProxyResponseFormatter { + up := &updateProxyResponseFormatter{ + onlyToken: false, + formatter: cliui.NewOutputFormatter( + // Text formatter should be human readable. + cliui.ChangeFormatterData(cliui.TextFormat(), func(data any) (any, error) { + response, ok := data.(codersdk.UpdateWorkspaceProxyResponse) + if !ok { + return nil, xerrors.Errorf("unexpected type %T", data) + } + return fmt.Sprintf("Workspace Proxy %q created successfully. Save this token, it will not be shown again."+ + "\nToken: %s", response.Proxy.Name, response.ProxyToken), nil + }), + cliui.JSONFormat(), + // Table formatter expects a slice, make a slice of one. + cliui.ChangeFormatterData(cliui.TableFormat([]codersdk.UpdateWorkspaceProxyResponse{}, []string{"proxy name", "proxy url", "proxy token"}), + func(data any) (any, error) { + response, ok := data.(codersdk.UpdateWorkspaceProxyResponse) + if !ok { + return nil, xerrors.Errorf("unexpected type %T", data) + } + return []codersdk.UpdateWorkspaceProxyResponse{response}, nil + }), + ), + } + + return up +} diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 190a552a80c99..6663bf6e53c44 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -120,6 +120,8 @@ func New(ctx context.Context, options *Options) (*API, error) { httpmw.ExtractWorkspaceProxyParam(api.Database), ) + r.Get("/", api.workspaceProxy) + r.Patch("/", api.patchWorkspaceProxy) r.Delete("/", api.deleteWorkspaceProxy) }) }) diff --git a/enterprise/coderd/workspaceproxy.go b/enterprise/coderd/workspaceproxy.go index bd4e910838f49..e8b24bb2c7f48 100644 --- a/enterprise/coderd/workspaceproxy.go +++ b/enterprise/coderd/workspaceproxy.go @@ -90,6 +90,79 @@ func (api *API) regions(rw http.ResponseWriter, r *http.Request) { }) } +// @Summary Update workspace proxy +// @ID update-workspace-proxy +// @Security CoderSessionToken +// @Accept json +// @Produce json +// @Tags Enterprise +// @Param workspaceproxy path string true "Proxy ID or name" format(uuid) +// @Param request body codersdk.PatchWorkspaceProxy true "Update workspace proxy request" +// @Success 200 {object} codersdk.WorkspaceProxy +// @Router /workspaceproxies/{workspaceproxy} [patch] +func (api *API) patchWorkspaceProxy(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + proxy = httpmw.WorkspaceProxyParam(r) + auditor = api.AGPL.Auditor.Load() + aReq, commitAudit = audit.InitRequest[database.WorkspaceProxy](rw, &audit.RequestParams{ + Audit: *auditor, + Log: api.Logger, + Request: r, + Action: database.AuditActionWrite, + }) + ) + aReq.Old = proxy + defer commitAudit() + + var req codersdk.PatchWorkspaceProxy + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + var hashedSecret []byte + var fullToken string + if req.RegenerateToken { + var err error + fullToken, hashedSecret, err = generateWorkspaceProxyToken(proxy.ID) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + } + + updatedProxy, err := api.Database.UpdateWorkspaceProxy(ctx, database.UpdateWorkspaceProxyParams{ + Name: req.Name, + DisplayName: req.DisplayName, + Icon: req.Icon, + ID: proxy.ID, + // If hashedSecret is nil or empty, this will not update the secret. + TokenHashedSecret: hashedSecret, + }) + if httpapi.Is404Error(err) { + httpapi.ResourceNotFound(rw) + return + } + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + aReq.New = updatedProxy + status, ok := api.ProxyHealth.HealthStatus()[updatedProxy.ID] + if !ok { + // The proxy should have some status, but just in case. + status.Status = proxyhealth.Unknown + } + httpapi.Write(ctx, rw, http.StatusOK, codersdk.UpdateWorkspaceProxyResponse{ + Proxy: convertProxy(updatedProxy, status), + ProxyToken: fullToken, + }) + + // Update the proxy cache. + go api.forceWorkspaceProxyHealthUpdate(api.ctx) +} + // @Summary Delete workspace proxy // @ID delete-workspace-proxy // @Security CoderSessionToken @@ -107,7 +180,7 @@ func (api *API) deleteWorkspaceProxy(rw http.ResponseWriter, r *http.Request) { Audit: *auditor, Log: api.Logger, Request: r, - Action: database.AuditActionCreate, + Action: database.AuditActionDelete, }) ) aReq.Old = proxy @@ -135,6 +208,23 @@ func (api *API) deleteWorkspaceProxy(rw http.ResponseWriter, r *http.Request) { go api.forceWorkspaceProxyHealthUpdate(api.ctx) } +// @Summary Get workspace proxy +// @ID get-workspace-proxy +// @Security CoderSessionToken +// @Produce json +// @Tags Enterprise +// @Param workspaceproxy path string true "Proxy ID or name" format(uuid) +// @Success 200 {object} codersdk.WorkspaceProxy +// @Router /workspaceproxies/{workspaceproxy} [get] +func (api *API) workspaceProxy(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + proxy = httpmw.WorkspaceProxyParam(r) + ) + + httpapi.Write(ctx, rw, http.StatusOK, convertProxy(proxy, api.ProxyHealth.HealthStatus()[proxy.ID])) +} + // @Summary Create workspace proxy // @ID create-workspace-proxy // @Security CoderSessionToken @@ -177,13 +267,11 @@ func (api *API) postWorkspaceProxy(rw http.ResponseWriter, r *http.Request) { } id := uuid.New() - secret, err := cryptorand.HexString(64) + fullToken, hashedSecret, err := generateWorkspaceProxyToken(id) if err != nil { httpapi.InternalServerError(rw, err) return } - hashedSecret := sha256.Sum256([]byte(secret)) - fullToken := fmt.Sprintf("%s:%s", id, secret) proxy, err := api.Database.InsertWorkspaceProxy(ctx, database.InsertWorkspaceProxyParams{ ID: id, @@ -206,7 +294,7 @@ func (api *API) postWorkspaceProxy(rw http.ResponseWriter, r *http.Request) { } aReq.New = proxy - httpapi.Write(ctx, rw, http.StatusCreated, codersdk.CreateWorkspaceProxyResponse{ + httpapi.Write(ctx, rw, http.StatusCreated, codersdk.UpdateWorkspaceProxyResponse{ Proxy: convertProxy(proxy, proxyhealth.ProxyStatus{ Proxy: proxy, CheckedAt: time.Now(), @@ -325,7 +413,20 @@ func (api *API) workspaceProxyRegister(rw http.ResponseWriter, r *http.Request) var ( ctx = r.Context() proxy = httpmw.WorkspaceProxy(r) + // TODO: This audit log does not work because it has no user id + // associated with it. The audit log commitAudit() function ignores + // the audit log if there is no user id. We should find a solution + // to make sure this event is tracked. + // auditor = api.AGPL.Auditor.Load() + // aReq, commitAudit = audit.InitRequest[database.WorkspaceProxy](rw, &audit.RequestParams{ + // Audit: *auditor, + // Log: api.Logger, + // Request: r, + // Action: database.AuditActionWrite, + //}) ) + // aReq.Old = proxy + // defer commitAudit() var req wsproxysdk.RegisterWorkspaceProxyRequest if !httpapi.Read(ctx, rw, r, &req) { @@ -364,6 +465,7 @@ func (api *API) workspaceProxyRegister(rw http.ResponseWriter, r *http.Request) return } + // aReq.New = updatedProxy httpapi.Write(ctx, rw, http.StatusCreated, wsproxysdk.RegisterWorkspaceProxyResponse{ AppSecurityKey: api.AppSecurityKey.String(), }) @@ -467,6 +569,16 @@ func (api *API) reconnectingPTYSignedToken(rw http.ResponseWriter, r *http.Reque }) } +func generateWorkspaceProxyToken(id uuid.UUID) (token string, hashed []byte, err error) { + secret, err := cryptorand.HexString(64) + if err != nil { + return "", nil, xerrors.Errorf("generate token: %w", err) + } + hashedSecret := sha256.Sum256([]byte(secret)) + fullToken := fmt.Sprintf("%s:%s", id, secret) + return fullToken, hashedSecret[:], nil +} + func convertProxies(p []database.WorkspaceProxy, statuses map[uuid.UUID]proxyhealth.ProxyStatus) []codersdk.WorkspaceProxy { resp := make([]codersdk.WorkspaceProxy, 0, len(p)) for _, proxy := range p { @@ -479,6 +591,7 @@ func convertProxy(p database.WorkspaceProxy, status proxyhealth.ProxyStatus) cod return codersdk.WorkspaceProxy{ ID: p.ID, Name: p.Name, + DisplayName: p.DisplayName, Icon: p.Icon, URL: p.Url, WildcardHostname: p.WildcardHostname, diff --git a/enterprise/coderd/workspaceproxy_test.go b/enterprise/coderd/workspaceproxy_test.go index 4a48a0b7349da..ad00b07d7f9ed 100644 --- a/enterprise/coderd/workspaceproxy_test.go +++ b/enterprise/coderd/workspaceproxy_test.go @@ -177,7 +177,7 @@ func TestRegions(t *testing.T) { func TestWorkspaceProxyCRUD(t *testing.T) { t.Parallel() - t.Run("create", func(t *testing.T) { + t.Run("CreateAndUpdate", func(t *testing.T) { t.Parallel() dv := coderdtest.DeploymentValues(t) @@ -203,14 +203,33 @@ func TestWorkspaceProxyCRUD(t *testing.T) { }) require.NoError(t, err) - proxies, err := client.WorkspaceProxies(ctx) + found, err := client.WorkspaceProxyByID(ctx, proxyRes.Proxy.ID) require.NoError(t, err) - require.Len(t, proxies, 1) - require.Equal(t, proxyRes.Proxy.ID, proxies[0].ID) + // This will be different, so set it to the same + found.Status = proxyRes.Proxy.Status + require.Equal(t, proxyRes.Proxy, found, "expected proxy") require.NotEmpty(t, proxyRes.ProxyToken) + + // Update the proxy + expName := namesgenerator.GetRandomName(1) + expDisplayName := namesgenerator.GetRandomName(1) + expIcon := namesgenerator.GetRandomName(1) + _, err = client.PatchWorkspaceProxy(ctx, codersdk.PatchWorkspaceProxy{ + ID: proxyRes.Proxy.ID, + Name: expName, + DisplayName: expDisplayName, + Icon: expIcon, + }) + require.NoError(t, err, "expected no error updating proxy") + + found, err = client.WorkspaceProxyByID(ctx, proxyRes.Proxy.ID) + require.NoError(t, err) + require.Equal(t, expName, found.Name, "name") + require.Equal(t, expDisplayName, found.DisplayName, "display name") + require.Equal(t, expIcon, found.Icon, "icon") }) - t.Run("delete", func(t *testing.T) { + t.Run("Delete", func(t *testing.T) { t.Parallel() dv := coderdtest.DeploymentValues(t) diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 8bc4b1e6aedc7..49618413b5ece 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -264,12 +264,6 @@ export interface CreateWorkspaceProxyRequest { readonly icon: string } -// From codersdk/workspaceproxy.go -export interface CreateWorkspaceProxyResponse { - readonly proxy: WorkspaceProxy - readonly proxy_token: string -} - // From codersdk/organizations.go export interface CreateWorkspaceRequest { readonly template_id: string @@ -632,6 +626,15 @@ export interface PatchTemplateVersionRequest { readonly name: string } +// From codersdk/workspaceproxy.go +export interface PatchWorkspaceProxy { + readonly id: string + readonly name: string + readonly display_name: string + readonly icon: string + readonly regenerate_token: boolean +} + // From codersdk/deployment.go export interface PprofConfig { readonly enable: boolean @@ -1034,6 +1037,12 @@ export interface UpdateWorkspaceAutostartRequest { readonly schedule?: string } +// From codersdk/workspaceproxy.go +export interface UpdateWorkspaceProxyResponse { + readonly proxy: WorkspaceProxy + readonly proxy_token: string +} + // From codersdk/workspaces.go export interface UpdateWorkspaceRequest { readonly name?: string @@ -1264,6 +1273,7 @@ export interface WorkspaceOptions { export interface WorkspaceProxy { readonly id: string readonly name: string + readonly display_name: string readonly icon: string readonly url: string readonly wildcard_hostname: string