From 16ad5d9f77ac77a113b065baca309022d7f2ae2d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:07:53 -0700 Subject: [PATCH 1/8] fix(coderd): reject workspace proxy hostname prefixes (#27544) (#27614) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of https://github.com/coder/coder/pull/27544 Original PR: #27544 — fix(coderd): reject workspace proxy hostname prefixes Merge commit: 8cc7f2bb0e0b33199393cc3773c7d691bd2fbf6b Requested by: @geokat Co-authored-by: George K Co-authored-by: Bobby Ho --- coderd/database/querier_test.go | 52 +++++++++++++++++++++++++++++ coderd/database/queries.sql.go | 2 +- coderd/database/queries/proxies.sql | 2 +- coderd/workspaceapps_test.go | 9 +++++ 4 files changed, 63 insertions(+), 2 deletions(-) diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 09f143538ea..0e4d2e1b31b 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -1305,6 +1305,16 @@ func TestProxyByHostname(t *testing.T) { accessURL: "https://two.coder.com", wildcardHostname: "*--suffix.two.coder.com", }, + { + name: "three", + accessURL: "https://three.coder.com:8443", + wildcardHostname: "*.wildcard.three.coder.com", + }, + { + name: "four", + accessURL: "https://four.coder.com/", + wildcardHostname: "*.wildcard.four.coder.com", + }, } for _, p := range proxies { dbgen.WorkspaceProxy(t, db, database.WorkspaceProxy{ @@ -1335,6 +1345,34 @@ func TestProxyByHostname(t *testing.T) { allowWildcardHost: true, matchProxyName: "one", }, + { + name: "MatchAccessURLWithPort", + testHostname: "three.coder.com", + allowAccessURL: true, + allowWildcardHost: false, + matchProxyName: "three", + }, + { + name: "MatchAccessURLWithTrailingSlash", + testHostname: "four.coder.com", + allowAccessURL: true, + allowWildcardHost: false, + matchProxyName: "four", + }, + { + name: "RejectAccessURLPrefix", + testHostname: "one.coder", + allowAccessURL: true, + allowWildcardHost: false, + matchProxyName: "", + }, + { + name: "RejectAccessURLTLDPrefix", + testHostname: "one.coder.co", + allowAccessURL: true, + allowWildcardHost: false, + matchProxyName: "", + }, { name: "MatchWildcard", testHostname: "something.wildcard.one.coder.com", @@ -1342,6 +1380,13 @@ func TestProxyByHostname(t *testing.T) { allowWildcardHost: true, matchProxyName: "one", }, + { + name: "RejectWildcardHostnamePrefix", + testHostname: "something.wildcard.one.coder", + allowAccessURL: false, + allowWildcardHost: true, + matchProxyName: "", + }, { name: "MatchSuffix", testHostname: "something--suffix.two.coder.com", @@ -1349,6 +1394,13 @@ func TestProxyByHostname(t *testing.T) { allowWildcardHost: true, matchProxyName: "two", }, + { + name: "RejectSuffixHostnamePrefix", + testHostname: "something--suffix.two.coder", + allowAccessURL: false, + allowWildcardHost: true, + matchProxyName: "", + }, { name: "ValidateHostname/1", testHostname: ".*ne.coder.com", diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 4e32eed748d..572c8d2e9c3 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -11298,7 +11298,7 @@ WHERE ( ( $2 :: bool = true AND - url SIMILAR TO '[^:]*://' || $1 :: text || '([:/]?%)*' + url SIMILAR TO '[^:]*://' || $1 :: text || '([:/]%)*' ) OR ( $3 :: bool = true AND diff --git a/coderd/database/queries/proxies.sql b/coderd/database/queries/proxies.sql index df59d3baf10..cac44de84d4 100644 --- a/coderd/database/queries/proxies.sql +++ b/coderd/database/queries/proxies.sql @@ -120,7 +120,7 @@ WHERE ( ( @allow_access_url :: bool = true AND - url SIMILAR TO '[^:]*://' || @hostname :: text || '([:/]?%)*' + url SIMILAR TO '[^:]*://' || @hostname :: text || '([:/]%)*' ) OR ( @allow_wildcard_hostname :: bool = true AND diff --git a/coderd/workspaceapps_test.go b/coderd/workspaceapps_test.go index 8db2858e01e..db6f77ab951 100644 --- a/coderd/workspaceapps_test.go +++ b/coderd/workspaceapps_test.go @@ -115,6 +115,15 @@ func TestWorkspaceApplicationAuth(t *testing.T) { redirectURI: "https://proxy.test.coder.com/path", expectRedirect: "https://proxy.test.coder.com/path", }, + { + name: "RejectProxyAccessURLPrefix", + accessURL: "https://test.coder.com", + appHostname: "*.test.coder.com", + proxyURL: "https://proxy.test.coder.com", + proxyAppHostname: "*.proxy.test.coder.com", + redirectURI: "https://proxy.test.coder/path", + expectRedirect: "", + }, { name: "ProxySubdomainOK", accessURL: "https://test.coder.com", From d1cc073cc59e0d82c743d10d4d84533427d2c8b0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:51:29 -0400 Subject: [PATCH 2/8] docs: fix P2/P3 typos and syntax errors from drift sweep (#28101) (#28157) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of https://github.com/coder/coder/pull/28101 Original PR: #28101 — docs: fix P2/P3 typos and syntax errors from drift sweep Merge commit: 1d189cc204f935cac6ce16de162fd7b897652452 Requested by: @nickvigilante Co-authored-by: Nick Vigilante --- docs/about/contributing/modules.md | 2 +- docs/admin/networking/port-forwarding.md | 2 +- docs/admin/templates/extending-templates/variables.md | 2 +- docs/admin/users/idp-sync.md | 4 ++-- docs/ai-coder/tasks-migration.md | 6 +++--- docs/user-guides/workspace-access/index.md | 2 +- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/about/contributing/modules.md b/docs/about/contributing/modules.md index 05d06e9299f..835729c3065 100644 --- a/docs/about/contributing/modules.md +++ b/docs/about/contributing/modules.md @@ -342,7 +342,7 @@ Use the version bump script to update versions: ```bash git add . - git commit -m "feat(git-clone):add git-clone module" + git commit -m "feat(git-clone): add git-clone module" ``` 4. **Open a pull request**: diff --git a/docs/admin/networking/port-forwarding.md b/docs/admin/networking/port-forwarding.md index 4f117775a4e..a028b17aadd 100644 --- a/docs/admin/networking/port-forwarding.md +++ b/docs/admin/networking/port-forwarding.md @@ -60,7 +60,7 @@ where each segment of hostnames must not exceed 63 characters. If your app name, agent name, workspace name and username exceed 63 characters in the hostname, port forwarding via the dashboard will not work. -### From an coder_app resource +### From a coder_app resource One way to port forward is to configure a `coder_app` resource in the workspace's template. This approach shows a visual application icon in the diff --git a/docs/admin/templates/extending-templates/variables.md b/docs/admin/templates/extending-templates/variables.md index 3c1d02f0baf..7776b355a3c 100644 --- a/docs/admin/templates/extending-templates/variables.md +++ b/docs/admin/templates/extending-templates/variables.md @@ -51,7 +51,7 @@ and predictability. If you encounter a situation where you need to override template settings for variables, you can employ a straightforward solution: -1. Create a `terraform.tfvars` file in in the template directory: +1. Create a `terraform.tfvars` file in the template directory: ```tf coder_image = newimage:tag diff --git a/docs/admin/users/idp-sync.md b/docs/admin/users/idp-sync.md index 3c7ec708be3..824d66635fd 100644 --- a/docs/admin/users/idp-sync.md +++ b/docs/admin/users/idp-sync.md @@ -241,7 +241,7 @@ role sync at the organization level. 1. Confirm you have the [Coder CLI](../../install/index.md) installed and are logged in with a user who is an Owner or has an Organization Admin role. -1. To fetch the current group sync settings for an organization, run the +1. To fetch the current role sync settings for an organization, run the following: ```sh @@ -388,7 +388,7 @@ settings, a user's memberships will update when they log out and log back in. "cbdcf774-4123-4118-8cd9-b3f502c84dfb" ], "sales": [ - "d79144d9-b30a-555a-9af8-7dac83b2q4ec", + "d79144d9-b30a-555a-9af8-7dac83b2q4ec" ] }, "organization_assign_default": true diff --git a/docs/ai-coder/tasks-migration.md b/docs/ai-coder/tasks-migration.md index 6cd02ba2e7b..e3c21409f4b 100644 --- a/docs/ai-coder/tasks-migration.md +++ b/docs/ai-coder/tasks-migration.md @@ -68,7 +68,7 @@ Below is a minimal illustrative example of a Coder Tasks template pre-2.28.0. terraform { required_providers { coder = { - source = "coder/coder + source = "coder/coder" } } } @@ -124,8 +124,8 @@ Example (**not** a full template): terraform { required_providers { coder = { - source = "coder/coder - version = ">= 2.13.0 + source = "coder/coder" + version = ">= 2.13.0" } } } diff --git a/docs/user-guides/workspace-access/index.md b/docs/user-guides/workspace-access/index.md index 53b1583dac4..8c421fbb564 100644 --- a/docs/user-guides/workspace-access/index.md +++ b/docs/user-guides/workspace-access/index.md @@ -26,7 +26,7 @@ customization options, keyboard shortcuts, and troubleshooting guides. ## SSH -### Through with the CLI +### Through the CLI Coder will use the optimal path for an SSH connection (determined by your deployment's [networking configuration](../../admin/infrastructure/index.md)) From c85a3d253513f748b247ae48a6826033ea8ca866 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:53:20 -0400 Subject: [PATCH 3/8] docs: fix Helm TLS/ingress value keys in admin/setup (#28087) (#28162) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of https://github.com/coder/coder/pull/28087 Original PR: #28087 — docs: fix Helm TLS/ingress value keys in admin/setup Merge commit: 5b97d99a48677751023650064242b7b7e474160d Requested by: @nickvigilante Co-authored-by: Nick Vigilante --- docs/admin/setup/index.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/admin/setup/index.md b/docs/admin/setup/index.md index ea36467cfa1..df68b1efcbc 100644 --- a/docs/admin/setup/index.md +++ b/docs/admin/setup/index.md @@ -94,12 +94,14 @@ working directory prior to step 1. ```yaml coder: tls: - secretName: + secretNames: - coder-tls # Alternatively, if you use an Ingress controller to terminate TLS, # set the following values: ingress: + enable: true + tls: enable: true secretName: coder-tls wildcardSecretName: coder-tls From 8a49478da4fcf16ee2c07f3d3fe84a3c94eeba04 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:54:54 -0400 Subject: [PATCH 4/8] docs: correct nginx X-Forwarded-Proto and certbot instructions flavor (#28086) (#28160) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of https://github.com/coder/coder/pull/28086 Original PR: #28086 — docs: correct nginx X-Forwarded-Proto and certbot instructions flavor Merge commit: b0e93b6e3b599621d680a4fb7ed0f7534aa7b5e2 Requested by: @nickvigilante Co-authored-by: Nick Vigilante --- docs/tutorials/reverse-proxy-nginx.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/tutorials/reverse-proxy-nginx.md b/docs/tutorials/reverse-proxy-nginx.md index afc48cd6ef7..18dfd8a7344 100644 --- a/docs/tutorials/reverse-proxy-nginx.md +++ b/docs/tutorials/reverse-proxy-nginx.md @@ -54,7 +54,7 @@ you're using `coder.example.com` as your subdomain. ## Install and configure LetsEncrypt Certbot 1. Install LetsEncrypt Certbot: Refer to the - [CertBot documentation](https://certbot.eff.org/instructions?ws=apache&os=ubuntufocal&tab=wildcard). + [CertBot documentation](https://certbot.eff.org/instructions?ws=nginx&os=ubuntufocal&tab=wildcard). Be sure to pick the wildcard tab and select your DNS provider for instructions to install the necessary DNS plugin. @@ -134,7 +134,7 @@ providers, refer to the proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; + proxy_set_header X-Forwarded-Proto $scheme; add_header Strict-Transport-Security "max-age=15552000; includeSubDomains" always; } } From 1abc6f03f943e5fec660c5e9598c1042619f4e13 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:55:31 -0400 Subject: [PATCH 5/8] docs: remove invalid `--yes` flag from `coder template version promote` (#28084) (#28159) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of https://github.com/coder/coder/pull/28084 Original PR: #28084 — docs: remove invalid `--yes` flag from `coder template version promote` Merge commit: f3fd4c4a77f2bd15624310c49d0bcd1e6fad8ed6 Requested by: @nickvigilante Co-authored-by: Nick Vigilante --- docs/tutorials/testing-templates.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tutorials/testing-templates.md b/docs/tutorials/testing-templates.md index 025c0d6ace2..ef77d73302b 100644 --- a/docs/tutorials/testing-templates.md +++ b/docs/tutorials/testing-templates.md @@ -111,5 +111,5 @@ jobs: - name: Promote template version if: success() run: | - coder template version promote --template=$TEMPLATE_NAME --template-version=${{ steps.name.outputs.version_name }} --yes + coder template version promote --template=$TEMPLATE_NAME --template-version=${{ steps.name.outputs.version_name }} ``` From 625d2566f535a844d30a8d7986cce915dead3596 Mon Sep 17 00:00:00 2001 From: Nick Vigilante Date: Mon, 17 Aug 2026 14:49:11 -0400 Subject: [PATCH 6/8] fix(docs): correct broken CLI commands and flags from drift sweep (backport to release/2.29) (#28176) Backport of #28098 to `release/2.29` (ESR). Replaces the stale automated backport #28163, whose branch was an empty placeholder based on an older `release/2.29` (the branch predates four docs backports that have since merged: #28159, #28160, #28162, #28157). This branch is cut from the current `release/2.29` tip and cherry-picks `58de9ab8f87e` with `git cherry-pick -x`. **Conflicts resolved manually:** - `dx-data-cloud.md`, `users/index.md`: kept 2.29's `shell` fence and applied the `coder users list --output json | jq ... | @csv > users.csv` pipeline (same fence divergence as on `main`). - `docs/ai-coder/github-to-tasks.md`: that page does not exist on `release/2.29`, so its hunk is dropped. The backport touches `scale-utility.md`, `dx-data-cloud.md`, `users/index.md`, and `workspace-access/index.md`. Once reviewed, #28163 can be closed in favor of this PR. - Original PR: #28098 - Replaces: #28163 - Tracking: DOCS-651 > This PR was created with AI assistance (Coder Agents). --- docs/admin/infrastructure/scale-utility.md | 2 +- docs/admin/integrations/dx-data-cloud.md | 5 +++-- docs/admin/users/index.md | 5 +++-- docs/user-guides/workspace-access/index.md | 2 +- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/admin/infrastructure/scale-utility.md b/docs/admin/infrastructure/scale-utility.md index 6945b54bf55..0761b23a0ff 100644 --- a/docs/admin/infrastructure/scale-utility.md +++ b/docs/admin/infrastructure/scale-utility.md @@ -130,7 +130,7 @@ wish to clean up all workspaces, you can run the following command: ```shell coder exp scaletest cleanup \ --cleanup-job-timeout 2h \ - --cleanup-timeout 15min + --cleanup-timeout 15m ``` This will delete all workspaces and users with the prefix `scaletest-`. diff --git a/docs/admin/integrations/dx-data-cloud.md b/docs/admin/integrations/dx-data-cloud.md index 3556370535f..51ae222f961 100644 --- a/docs/admin/integrations/dx-data-cloud.md +++ b/docs/admin/integrations/dx-data-cloud.md @@ -30,10 +30,11 @@ If your organization already uses the Coder-DX integration, you can find a list ### CLI -Use `users list` to export the list of users to a CSV file: +Use `users list` with `jq` to export the list of users to a CSV file: ```shell -coder users list > users.csv +coder users list --output json | \ + jq -r '["username","email","created_at","status"], (.[] | [.username, .email, .created_at, .status]) | @csv' > users.csv ``` Visit the [users list](../../reference/cli/users_list.md) documentation for more options. diff --git a/docs/admin/users/index.md b/docs/admin/users/index.md index 4f6f5049d34..536300c4f7e 100644 --- a/docs/admin/users/index.md +++ b/docs/admin/users/index.md @@ -215,10 +215,11 @@ You can use the Coder CLI or API to retrieve your list of users. ### CLI -Use `users list` to export the list of users to a CSV file: +Use `users list` with `jq` to export the list of users to a CSV file: ```shell -coder users list > users.csv +coder users list --output json | \ + jq -r '["username","email","created_at","status"], (.[] | [.username, .email, .created_at, .status]) | @csv' > users.csv ``` Visit the [users list](../../reference/cli/users_list.md) documentation for more options. diff --git a/docs/user-guides/workspace-access/index.md b/docs/user-guides/workspace-access/index.md index 8c421fbb564..9975da1969e 100644 --- a/docs/user-guides/workspace-access/index.md +++ b/docs/user-guides/workspace-access/index.md @@ -156,7 +156,7 @@ services or preview environments. You can also [share ports](./port-forwarding.md#sharing-ports) with other users, or [port-forward](./port-forwarding.md#the-coder-port-forward-command) through -the CLI with `coder port forward`. Read more in the +the CLI with `coder port-forward`. Read more in the [docs on workspace ports](./port-forwarding.md). ![Open Ports window](../../images/networking/listeningports.png) From 97980f9e992261403bd0ea88c007c259dd21a346 Mon Sep 17 00:00:00 2001 From: Nick Vigilante Date: Mon, 24 Aug 2026 12:09:58 -0400 Subject: [PATCH 7/8] fix(docs): fix slack webhook backtick (backport of #28085 to release/2.29) (#28177) Backport of #28085 to `release/2.29` (ESR). Replaces the stale automated backport #28164, whose branch was an empty placeholder based on an older `release/2.29`. This branch is cut from the current `release/2.29` tip and cherry-picks `3145cc8386f9` with `git cherry-pick -x`. **Scope note:** only the `slack.md` fix (removing the stray trailing backtick from the `CODER_NOTIFICATIONS_WEBHOOK_ENDPOINT` export) applies to 2.29. #28085 also fixed a doubled-prefix metric name in `prometheus.md`, but `release/2.29`'s native-histograms list does not include `coderd_template_workspace_build_duration_seconds` at all, so that hunk is intentionally dropped rather than adding a metric 2.29 does not document. The `slack.md` conflict kept 2.29's `bash` fence. Once reviewed, #28164 can be closed in favor of this PR. - Original PR: #28085 - Replaces: #28164 - Tracking: DOCS-651 > This PR was created with AI assistance (Coder Agents). --- docs/admin/monitoring/notifications/slack.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/admin/monitoring/notifications/slack.md b/docs/admin/monitoring/notifications/slack.md index 99d5045656b..c521a546c83 100644 --- a/docs/admin/monitoring/notifications/slack.md +++ b/docs/admin/monitoring/notifications/slack.md @@ -196,7 +196,7 @@ To enable webhook integration in Coder, define the POST webhook endpoint matching the deployed Slack bot: ```bash -export CODER_NOTIFICATIONS_WEBHOOK_ENDPOINT=http://localhost:6000/v1/webhook` +export CODER_NOTIFICATIONS_WEBHOOK_ENDPOINT=http://localhost:6000/v1/webhook ``` Finally, go to the **Notification Settings** in Coder and switch the notifier to From 82fe9d172d484a5477578910b43496b63b570142 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 21:43:12 -0700 Subject: [PATCH 8/8] fix: prevent coderd crashes from unsupported Tailnet RPCs (#28429) (#28957) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of https://github.com/coder/coder/pull/28429 Original PR: #28429 — fix: prevent coderd crashes from unsupported Tailnet RPCs Merge commit: 156c8b6e41249e35af5dfdc93e867cdcd6579c33 Requested by: @geokat > [!WARNING] > The automatic cherry-pick had conflicts. > Please resolve manually by cherry-picking the original merge commit: > > ``` > git fetch origin backport/28429-to-2.29 > git checkout backport/28429-to-2.29 > git reset --hard origin/release/2.29 > git cherry-pick -x -m1 156c8b6e41249e35af5dfdc93e867cdcd6579c33 > # resolve conflicts, then push > ``` --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: George K --- agent/agentsocket/server.go | 2 +- agent/agenttest/client.go | 1 + coderd/agentapi/api.go | 32 +++++++- coderd/coderd.go | 2 +- coderd/workspaceagentsrpc_test.go | 44 +++++++++++ codersdk/drpcsdk/server.go | 39 ++++++++++ codersdk/drpcsdk/server_internal_test.go | 98 ++++++++++++++++++++++++ codersdk/drpcsdk/server_test.go | 95 +++++++++++++++++++++++ enterprise/coderd/aibridged.go | 2 +- enterprise/coderd/provisionerdaemons.go | 2 +- provisionersdk/serve.go | 2 +- tailnet/service.go | 17 +++- tailnet/service_test.go | 22 ++++++ 13 files changed, 348 insertions(+), 10 deletions(-) create mode 100644 codersdk/drpcsdk/server.go create mode 100644 codersdk/drpcsdk/server_internal_test.go create mode 100644 codersdk/drpcsdk/server_test.go diff --git a/agent/agentsocket/server.go b/agent/agentsocket/server.go index aed3afe4f72..eb82553527f 100644 --- a/agent/agentsocket/server.go +++ b/agent/agentsocket/server.go @@ -54,7 +54,7 @@ func NewServer(logger slog.Logger, opts ...Option) (*Server, error) { return nil, xerrors.Errorf("failed to register drpc service: %w", err) } - server.drpcServer = drpcserver.NewWithOptions(mux, drpcserver.Options{ + server.drpcServer = drpcsdk.NewServer(logger, mux, drpcserver.Options{ Manager: drpcsdk.DefaultDRPCOptions(nil), Log: func(err error) { if errors.Is(err, context.Canceled) || diff --git a/agent/agenttest/client.go b/agent/agenttest/client.go index ff601a7d083..295d9dae0e4 100644 --- a/agent/agenttest/client.go +++ b/agent/agenttest/client.go @@ -61,6 +61,7 @@ func NewClient(t testing.TB, fakeAAPI := NewFakeAgentAPI(t, logger, mp, statsChan) err = agentproto.DRPCRegisterAgent(mux, fakeAAPI) require.NoError(t, err) + // Keep panics unrecovered in this test server so they fail tests loudly. server := drpcserver.NewWithOptions(mux, drpcserver.Options{ Manager: drpcsdk.DefaultDRPCOptions(nil), Log: func(err error) { diff --git a/coderd/agentapi/api.go b/coderd/agentapi/api.go index 6c94bae760c..5657c558a48 100644 --- a/coderd/agentapi/api.go +++ b/coderd/agentapi/api.go @@ -55,7 +55,7 @@ type API struct { *ScriptsAPI *ConnLogAPI *SubAgentAPI - *tailnet.DRPCService + tailnetService *tailnet.DRPCService cachedWorkspaceFields *CachedWorkspaceFields @@ -64,6 +64,28 @@ type API struct { var _ agentproto.DRPCAgentServer = &API{} +// agentTailnetService exposes only Tailnet RPCs intended for workspace agents. +// Other current and future RPCs remain unavailable until explicitly forwarded. +type agentTailnetService struct { + tailnetproto.DRPCTailnetUnimplementedServer + + service *tailnet.DRPCService +} + +func (s *agentTailnetService) PostTelemetry(ctx context.Context, req *tailnetproto.TelemetryRequest) (*tailnetproto.TelemetryResponse, error) { + return s.service.PostTelemetry(ctx, req) +} + +func (s *agentTailnetService) StreamDERPMaps(req *tailnetproto.StreamDERPMapsRequest, stream tailnetproto.DRPCTailnet_StreamDERPMapsStream) error { + return s.service.StreamDERPMaps(req, stream) +} + +func (s *agentTailnetService) Coordinate(stream tailnetproto.DRPCTailnet_CoordinateStream) error { + return s.service.Coordinate(stream) +} + +var _ tailnetproto.DRPCTailnetServer = (*agentTailnetService)(nil) + type Options struct { AgentID uuid.UUID OwnerID uuid.UUID @@ -202,7 +224,7 @@ func New(opts Options, workspace database.Workspace) *API { Log: opts.Log, } - api.DRPCService = &tailnet.DRPCService{ + api.tailnetService = &tailnet.DRPCService{ CoordPtr: opts.TailnetCoordinator, Logger: opts.Log, DerpMapUpdateFrequency: opts.DerpMapUpdateFrequency, @@ -235,12 +257,14 @@ func (a *API) Server(ctx context.Context) (*drpcserver.Server, error) { return nil, xerrors.Errorf("register agent API protocol in DRPC mux: %w", err) } - err = tailnetproto.DRPCRegisterTailnet(mux, a) + err = tailnetproto.DRPCRegisterTailnet(mux, &agentTailnetService{ + service: a.tailnetService, + }) if err != nil { return nil, xerrors.Errorf("register tailnet API protocol in DRPC mux: %w", err) } - return drpcserver.NewWithOptions(&tracing.DRPCHandler{Handler: mux}, + return drpcsdk.NewServer(a.opts.Log, &tracing.DRPCHandler{Handler: mux}, drpcserver.Options{ Manager: drpcsdk.DefaultDRPCOptions(nil), Log: func(err error) { diff --git a/coderd/coderd.go b/coderd/coderd.go index 8133cee06cb..a940d1b3fa9 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -2059,7 +2059,7 @@ func (api *API) CreateInMemoryTaggedProvisionerDaemon(dialCtx context.Context, n if err != nil { return nil, err } - server := drpcserver.NewWithOptions(&tracing.DRPCHandler{Handler: mux}, + server := drpcsdk.NewServer(logger, &tracing.DRPCHandler{Handler: mux}, drpcserver.Options{ Manager: drpcsdk.DefaultDRPCOptions(nil), Log: func(err error) { diff --git a/coderd/workspaceagentsrpc_test.go b/coderd/workspaceagentsrpc_test.go index 525b8a981db..550e613d5f6 100644 --- a/coderd/workspaceagentsrpc_test.go +++ b/coderd/workspaceagentsrpc_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "storj.io/drpc/drpcerr" agentproto "github.com/coder/coder/v2/agent/proto" "github.com/coder/coder/v2/coderd/coderdtest" @@ -16,6 +17,8 @@ import ( "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/codersdk/agentsdk" "github.com/coder/coder/v2/provisionersdk/proto" + "github.com/coder/coder/v2/tailnet" + tailnetproto "github.com/coder/coder/v2/tailnet/proto" "github.com/coder/coder/v2/testutil" ) @@ -108,6 +111,47 @@ func TestWorkspaceAgentReportStats(t *testing.T) { } } +func TestWorkspaceAgentRPC_TailnetMethods(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db := coderdtest.NewWithDatabase(t, nil) + user := coderdtest.CreateFirstUser(t, client) + workspace := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OrganizationID: user.OrganizationID, + OwnerID: user.UserID, + }).WithAgent().Do() + + agentClient := agentsdk.New(client.URL, agentsdk.WithFixedToken(workspace.AgentToken)) + conn, err := agentClient.ConnectRPC(ctx) + require.NoError(t, err) + t.Cleanup(func() { + _ = conn.Close() + }) + + tailnetClient := tailnetproto.NewDRPCTailnetClient(conn) + _, err = tailnetClient.RefreshResumeToken(ctx, &tailnetproto.RefreshResumeTokenRequest{}) + require.Error(t, err) + require.EqualValues(t, drpcerr.Unimplemented, drpcerr.Code(err)) + + updates, err := tailnetClient.WorkspaceUpdates(ctx, &tailnetproto.WorkspaceUpdatesRequest{ + WorkspaceOwnerId: tailnet.UUIDToByteSlice(user.UserID), + }) + if err == nil { + _, err = updates.Recv() + } + require.Error(t, err) + require.EqualValues(t, drpcerr.Unimplemented, drpcerr.Code(err)) + + telemetry, err := tailnetClient.PostTelemetry(ctx, &tailnetproto.TelemetryRequest{}) + require.NoError(t, err) + require.NotNil(t, telemetry) + + agentAPI := agentproto.NewDRPCAgentClient(conn) + _, err = agentAPI.GetManifest(ctx, &agentproto.GetManifestRequest{}) + require.NoError(t, err) +} + func TestAgentAPI_LargeManifest(t *testing.T) { t.Parallel() diff --git a/codersdk/drpcsdk/server.go b/codersdk/drpcsdk/server.go new file mode 100644 index 00000000000..a649b742840 --- /dev/null +++ b/codersdk/drpcsdk/server.go @@ -0,0 +1,39 @@ +package drpcsdk + +import ( + "runtime/debug" + + "storj.io/drpc" + "storj.io/drpc/drpcserver" + + "cdr.dev/slog" +) + +// NewServer constructs a dRPC server that recovers panics from RPC handlers. +func NewServer(logger slog.Logger, handler drpc.Handler, options drpcserver.Options) *drpcserver.Server { + return drpcserver.NewWithOptions(&recoverHandler{ + logger: logger, + handler: handler, + }, options) +} + +type recoverHandler struct { + logger slog.Logger + handler drpc.Handler +} + +func (h *recoverHandler) HandleRPC(stream drpc.Stream, rpc string) (err error) { + defer func() { + if r := recover(); r != nil { + h.logger.Error(stream.Context(), + "panic serving dRPC request (recovered)", + slog.F("rpc", rpc), + slog.F("panic", r), + slog.F("stack", string(debug.Stack())), + ) + err = drpc.InternalError.New("panic serving dRPC request") + } + }() + + return h.handler.HandleRPC(stream, rpc) +} diff --git a/codersdk/drpcsdk/server_internal_test.go b/codersdk/drpcsdk/server_internal_test.go new file mode 100644 index 00000000000..f242f1608a6 --- /dev/null +++ b/codersdk/drpcsdk/server_internal_test.go @@ -0,0 +1,98 @@ +package drpcsdk + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/xerrors" + "storj.io/drpc" + + "cdr.dev/slog" +) + +func TestRecoverHandler(t *testing.T) { + t.Parallel() + + t.Run("Panic", func(t *testing.T) { + t.Parallel() + + const panicValue = "sensitive panic details" + sink := &recordingSink{} + handler := &recoverHandler{ + logger: slog.Make(sink), + handler: handlerFunc(func(drpc.Stream, string) error { + panic(panicValue) + }), + } + + err := handler.HandleRPC(contextStream{ctx: t.Context()}, "/test.Service/Panic") + require.Error(t, err) + require.True(t, drpc.InternalError.Has(err)) + require.NotContains(t, err.Error(), panicValue) + + entries := sink.Entries() + require.Len(t, entries, 1) + require.Equal(t, slog.LevelError, entries[0].Level) + require.Equal(t, "panic serving dRPC request (recovered)", entries[0].Message) + require.Equal(t, "/test.Service/Panic", fieldValue(entries[0].Fields, "rpc")) + require.Equal(t, panicValue, fieldValue(entries[0].Fields, "panic")) + stackValue := fieldValue(entries[0].Fields, "stack") + stack, ok := stackValue.(string) + require.True(t, ok, "stack field must be a string, got %T", stackValue) + require.Contains(t, stack, "goroutine ") + }) + + t.Run("Error", func(t *testing.T) { + t.Parallel() + + expected := xerrors.New("handler error") + handler := &recoverHandler{ + handler: handlerFunc(func(drpc.Stream, string) error { + return expected + }), + } + + err := handler.HandleRPC(contextStream{ctx: t.Context()}, "/test.Service/Error") + require.ErrorIs(t, err, expected) + }) +} + +type recordingSink struct { + entries []slog.SinkEntry +} + +func (s *recordingSink) LogEntry(_ context.Context, entry slog.SinkEntry) { + s.entries = append(s.entries, entry) +} + +func (*recordingSink) Sync() {} + +func (s *recordingSink) Entries() []slog.SinkEntry { + return append([]slog.SinkEntry(nil), s.entries...) +} + +type handlerFunc func(drpc.Stream, string) error + +func (f handlerFunc) HandleRPC(stream drpc.Stream, rpc string) error { + return f(stream, rpc) +} + +type contextStream struct { + ctx context.Context +} + +func (s contextStream) Context() context.Context { return s.ctx } +func (contextStream) MsgSend(drpc.Message, drpc.Encoding) error { return nil } +func (contextStream) MsgRecv(drpc.Message, drpc.Encoding) error { return nil } +func (contextStream) CloseSend() error { return nil } +func (contextStream) Close() error { return nil } + +func fieldValue(fields slog.Map, name string) any { + for _, field := range fields { + if field.Name == name { + return field.Value + } + } + return nil +} diff --git a/codersdk/drpcsdk/server_test.go b/codersdk/drpcsdk/server_test.go new file mode 100644 index 00000000000..34034fb8e37 --- /dev/null +++ b/codersdk/drpcsdk/server_test.go @@ -0,0 +1,95 @@ +package drpcsdk_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/xerrors" + "storj.io/drpc" + "storj.io/drpc/drpcserver" + + "cdr.dev/slog/sloggers/slogtest" + "github.com/coder/coder/v2/codersdk/drpcsdk" + "github.com/coder/coder/v2/testutil" +) + +func TestNewServerRecoversPanics(t *testing.T) { + t.Parallel() + + const ( + panicRPC = "/test.Service/Panic" + echoRPC = "/test.Service/Echo" + panicValue = "sensitive panic details" + ) + + ctx := testutil.Context(t, testutil.WaitShort) + serverCtx, cancel := context.WithCancel(ctx) + defer cancel() + + client, listener := drpcsdk.MemTransportPipe() + defer func() { + _ = client.Close() + _ = listener.Close() + }() + + handler := testHandlerFunc(func(stream drpc.Stream, rpc string) error { + switch rpc { + case panicRPC: + panic(panicValue) + case echoRPC: + var message string + if err := stream.MsgRecv(&message, stringEncoding{}); err != nil { + return err + } + return stream.MsgSend(&message, stringEncoding{}) + default: + return xerrors.Errorf("unexpected RPC %q", rpc) + } + }) + server := drpcsdk.NewServer(slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}), handler, drpcserver.Options{ + Manager: drpcsdk.DefaultDRPCOptions(nil), + }) + serverDone := make(chan error, 1) + go func() { + serverDone <- server.Serve(serverCtx, listener) + }() + + request, response := "request", "" + err := client.Invoke(ctx, panicRPC, stringEncoding{}, &request, &response) + require.EqualError(t, err, "internal error: panic serving dRPC request") + require.NotContains(t, err.Error(), panicValue) + + request, response = "healthy", "" + err = client.Invoke(ctx, echoRPC, stringEncoding{}, &request, &response) + require.NoError(t, err) + require.Equal(t, request, response) + + cancel() + require.NoError(t, testutil.RequireReceive(ctx, t, serverDone)) +} + +type testHandlerFunc func(drpc.Stream, string) error + +func (f testHandlerFunc) HandleRPC(stream drpc.Stream, rpc string) error { + return f(stream, rpc) +} + +type stringEncoding struct{} + +func (stringEncoding) Marshal(message drpc.Message) ([]byte, error) { + value, ok := message.(*string) + if !ok { + return nil, xerrors.Errorf("marshal %T: expected *string", message) + } + return []byte(*value), nil +} + +func (stringEncoding) Unmarshal(data []byte, message drpc.Message) error { + value, ok := message.(*string) + if !ok { + return xerrors.Errorf("unmarshal %T: expected *string", message) + } + *value = string(data) + return nil +} diff --git a/enterprise/coderd/aibridged.go b/enterprise/coderd/aibridged.go index 2ff2de902bc..229e4ee3fda 100644 --- a/enterprise/coderd/aibridged.go +++ b/enterprise/coderd/aibridged.go @@ -65,7 +65,7 @@ func (api *API) CreateInMemoryAIBridgeServer(dialCtx context.Context) (client ai if err != nil { return nil, xerrors.Errorf("register key validator service: %w", err) } - server := drpcserver.NewWithOptions(&tracing.DRPCHandler{Handler: mux}, + server := drpcsdk.NewServer(api.Logger, &tracing.DRPCHandler{Handler: mux}, drpcserver.Options{ Manager: drpcsdk.DefaultDRPCOptions(nil), Log: func(err error) { diff --git a/enterprise/coderd/provisionerdaemons.go b/enterprise/coderd/provisionerdaemons.go index b5c22dd97a8..5bf13419a90 100644 --- a/enterprise/coderd/provisionerdaemons.go +++ b/enterprise/coderd/provisionerdaemons.go @@ -376,7 +376,7 @@ func (api *API) provisionerDaemonServe(rw http.ResponseWriter, r *http.Request) _ = conn.Close(websocket.StatusInternalError, httpapi.WebsocketCloseSprintf("drpc register provisioner daemon: %s", err)) return } - server := drpcserver.NewWithOptions(mux, drpcserver.Options{ + server := drpcsdk.NewServer(logger, mux, drpcserver.Options{ Manager: drpcsdk.DefaultDRPCOptions(nil), Log: func(err error) { if xerrors.Is(err, io.EOF) { diff --git a/provisionersdk/serve.go b/provisionersdk/serve.go index 3bac226e583..a6441678261 100644 --- a/provisionersdk/serve.go +++ b/provisionersdk/serve.go @@ -84,7 +84,7 @@ func Serve(ctx context.Context, server Server, options *ServeOptions) error { if err != nil { return xerrors.Errorf("register provisioner: %w", err) } - srv := drpcserver.NewWithOptions(&tracing.DRPCHandler{Handler: mux}, drpcserver.Options{ + srv := drpcsdk.NewServer(options.Logger, &tracing.DRPCHandler{Handler: mux}, drpcserver.Options{ Manager: drpcsdk.DefaultDRPCOptions(nil), }) diff --git a/tailnet/service.go b/tailnet/service.go index c3a6b9f2b28..0112466f2a3 100644 --- a/tailnet/service.go +++ b/tailnet/service.go @@ -11,6 +11,7 @@ import ( "github.com/google/uuid" "github.com/hashicorp/yamux" "golang.org/x/xerrors" + "storj.io/drpc/drpcerr" "storj.io/drpc/drpcmux" "storj.io/drpc/drpcserver" "tailscale.com/tailcfg" @@ -92,7 +93,7 @@ func NewClientService(options ClientServiceOptions) ( if err != nil { return nil, xerrors.Errorf("register DRPC service: %w", err) } - server := drpcserver.NewWithOptions(mux, drpcserver.Options{ + server := drpcsdk.NewServer(options.Logger, mux, drpcserver.Options{ Manager: drpcsdk.DefaultDRPCOptions(nil), Log: func(err error) { if xerrors.Is(err, io.EOF) || @@ -185,6 +186,13 @@ func (s *DRPCService) StreamDERPMaps(_ *proto.StreamDERPMapsRequest, stream prot } func (s *DRPCService) RefreshResumeToken(ctx context.Context, _ *proto.RefreshResumeTokenRequest) (*proto.RefreshResumeTokenResponse, error) { + if s.ResumeTokenProvider == nil { + return nil, drpcerr.WithCode( + xerrors.New("resume tokens not supported on this connection"), + drpcerr.Unimplemented, + ) + } + streamID, ok := ctx.Value(streamIDContextKey{}).(StreamID) if !ok { return nil, xerrors.New("no Stream ID") @@ -219,6 +227,13 @@ func (s *DRPCService) Coordinate(stream proto.DRPCTailnet_CoordinateStream) erro } func (s *DRPCService) WorkspaceUpdates(req *proto.WorkspaceUpdatesRequest, stream proto.DRPCTailnet_WorkspaceUpdatesStream) error { + if s.WorkspaceUpdatesProvider == nil { + return drpcerr.WithCode( + xerrors.New("workspace updates not supported on this connection"), + drpcerr.Unimplemented, + ) + } + defer stream.Close() ctx := stream.Context() diff --git a/tailnet/service_test.go b/tailnet/service_test.go index 0c268b05edb..21a290ef358 100644 --- a/tailnet/service_test.go +++ b/tailnet/service_test.go @@ -13,6 +13,7 @@ import ( "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" "golang.org/x/xerrors" + "storj.io/drpc/drpcerr" "tailscale.com/tailcfg" "github.com/coder/coder/v2/tailnet" @@ -178,6 +179,27 @@ func TestClientService_ServeClient_V1(t *testing.T) { require.ErrorIs(t, err, tailnet.ErrUnsupportedVersion) } +func TestClientService_UnsupportedProviders(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + clientID := uuid.New() + _, client := createUpdateService(t, ctx, clientID, nil) + + _, err := client.RefreshResumeToken(ctx, &proto.RefreshResumeTokenRequest{}) + require.ErrorContains(t, err, "resume tokens not supported on this connection") + require.EqualValues(t, drpcerr.Unimplemented, drpcerr.Code(err)) + + updates, err := client.WorkspaceUpdates(ctx, &proto.WorkspaceUpdatesRequest{ + WorkspaceOwnerId: tailnet.UUIDToByteSlice(clientID), + }) + if err == nil { + _, err = updates.Recv() + } + require.ErrorContains(t, err, "workspace updates not supported on this connection") + require.EqualValues(t, drpcerr.Unimplemented, drpcerr.Code(err)) +} + func TestNetworkTelemetryBatcher(t *testing.T) { t.Parallel()