Skip to content

feat(envd): add WebSocket raw TCP tunnel endpoint#2942

Open
mishushakov wants to merge 6 commits into
mainfrom
mishushakov/envd-wss-tunnels
Open

feat(envd): add WebSocket raw TCP tunnel endpoint#2942
mishushakov wants to merge 6 commits into
mainfrom
mishushakov/envd-wss-tunnels

Conversation

@mishushakov

Copy link
Copy Markdown
Member

Adds a GET /tunnel WebSocket endpoint to envd that tunnels raw TCP to a target host:port inside the sandbox: the target is passed via host/port query params, validated and dialed before the upgrade so bad params return 400 and connection failures 502 as ordinary HTTP responses, and after the 101 upgrade the connection becomes a verbatim bidirectional byte relay (binary frames, no framing) with a 30s keepalive ping to survive the upstream proxies' idle timeouts. Authentication reuses the existing X-Access-Token header check, and the endpoint is defined in spec/envd.yaml and generated (api.gen.go) like every other envd route. The default host is localhost; any host is allowed since the sandbox is the user's own environment. Includes unit tests covering the echo relay, host defaulting, and the auth/400/502 paths, and bumps the envd version to 0.7.0 (it uses github.com/coder/websocket, already vendored elsewhere in the repo at v1.8.13).

🤖 Generated with Claude Code

Add a GET /tunnel WebSocket endpoint to envd that relays raw TCP bytes
between a client and a target host:port inside the sandbox. The target is
given via host/port query params, validated and dialed before the upgrade so
bad params return 400 and connection failures 502 as normal HTTP responses;
after the 101 upgrade the connection is a verbatim bidirectional relay with a
keepalive ping to survive the upstream proxies' idle timeouts. Auth reuses the
existing X-Access-Token header check, and the endpoint is documented in the
OpenAPI spec and generated like every other envd route.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@cursor

cursor Bot commented Jun 6, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
New network tunnel surface gated by the access token; permissive WebSocket origin handling and unrestricted in-sandbox dial targets increase impact if credentials leak or clients are misused.

Overview
Adds a WebSocket GET /tunnel on envd so clients with X-Access-Token can reach a sandbox TCP host:port (default host localhost) as a raw byte relay, with pre-upgrade validation/dial for 400/502 responses, keepalive pings, OpenAPI/codegen updates, tests, and github.com/coder/websocket as a direct dependency.

The PR description says envd 0.7.0 but packages/envd/pkg/version.go is bumped only to 0.6.4. After a successful TCP dial, a failed WebSocket upgrade still ties up the target until the handler returns (usually short, but not zero). host is not restricted, so any in-sandbox address the network stack can reach is fair game once the token is valid. InsecureSkipVerify: true on accept matches other permissive CORS behavior but widens cross-origin WebSocket use if a token is exposed to browser code.

Reviewed by Cursor Bugbot for commit cee340d. Bugbot is set up for automated code reviews on this repo. Configure here.

@codecov

codecov Bot commented Jun 6, 2026

Copy link
Copy Markdown

❌ 7 Tests Failed:

Tests completed Failed Passed Skipped
2788 7 2781 5
View the top 3 failed test(s) by shortest run time
github.com/e2b-dev/infra/tests/integration/internal/tests/api/sandboxes::TestSnapshotTemplateDelete
Stack Traces | 0s run time
=== RUN   TestSnapshotTemplateDelete
=== PAUSE TestSnapshotTemplateDelete
=== CONT  TestSnapshotTemplateDelete
--- FAIL: TestSnapshotTemplateDelete (0.00s)
github.com/e2b-dev/infra/tests/integration/internal/tests/proxies::TestEnvdAccessTokenAutoResumeViaProxy
Stack Traces | 11.2s run time
=== RUN   TestEnvdAccessTokenAutoResumeViaProxy
=== PAUSE TestEnvdAccessTokenAutoResumeViaProxy
=== CONT  TestEnvdAccessTokenAutoResumeViaProxy
    traffic_access_token_test.go:357: 
        	Error Trace:	.../tests/proxies/traffic_access_token_test.go:357
        	Error:      	Received unexpected error:
        	            	Get "http://localhost:3002/health": context deadline exceeded (Client.Timeout exceeded while awaiting headers)
        	Test:       	TestEnvdAccessTokenAutoResumeViaProxy
--- FAIL: TestEnvdAccessTokenAutoResumeViaProxy (11.22s)
github.com/e2b-dev/infra/tests/integration/internal/tests/api/sandboxes::TestSnapshotTemplateDelete/delete_unnamed_snapshot_by_name
Stack Traces | 21.7s run time
=== RUN   TestSnapshotTemplateDelete/delete_unnamed_snapshot_by_name
=== PAUSE TestSnapshotTemplateDelete/delete_unnamed_snapshot_by_name
=== CONT  TestSnapshotTemplateDelete/delete_unnamed_snapshot_by_name
    snapshot_template_test.go:169: 
        	Error Trace:	.../api/sandboxes/snapshot_template_test.go:37
        	            				.../api/sandboxes/snapshot_template_test.go:169
        	Error:      	Not equal: 
        	            	expected: 201
        	            	actual  : 500
        	Test:       	TestSnapshotTemplateDelete/delete_unnamed_snapshot_by_name
--- FAIL: TestSnapshotTemplateDelete/delete_unnamed_snapshot_by_name (21.73s)
View the full list of 4 ❄️ flaky test(s)
github.com/e2b-dev/infra/tests/integration/internal/tests/api/sandboxes::TestSandboxListPaginationRunningLargerLimit

Flake rate in main: 38.29% (Passed 996 times, Failed 618 times)

Stack Traces | 99.4s run time
=== RUN   TestSandboxListPaginationRunningLargerLimit
    sandbox_list_test.go:327: Created sandbox 1/12: ixv5e2gzkd0jusk16pd4k
    sandbox_list_test.go:327: Created sandbox 2/12: ihp35v0zsk6uv55xqivh4
    sandbox_list_test.go:327: Created sandbox 3/12: i44938afhriog46prd9gs
    sandbox_list_test.go:327: Created sandbox 4/12: igsbsu0xpqw24xqpvcrlg
    sandbox_list_test.go:327: Created sandbox 5/12: infiw4x45fu6kayy57ehf
    sandbox_list_test.go:327: Created sandbox 6/12: i639hvrxvirklzb4u1eal
    sandbox_list_test.go:327: Created sandbox 7/12: i6w91qdo0kti629ycmtj9
    sandbox_list_test.go:327: Created sandbox 8/12: i51eu2y7elosx437fla5n
    sandbox_list_test.go:327: Created sandbox 9/12: iovbxnobvez014pdw84yy
    sandbox_list_test.go:327: Created sandbox 10/12: i2yhn7jnuls15txfkzcxx
    sandbox_list_test.go:327: Created sandbox 11/12: ifg95jr9k1faxb1wbdy3i
    sandbox_list_test.go:327: Created sandbox 12/12: i61vrj0t1g43j33zv0pkt
    sandbox_list_test.go:330: 
        	Error Trace:	.../api/sandboxes/sandbox_list_test.go:340
        	            				.../hostedtoolcache/go/1.26.3.../src/runtime/asm_amd64.s:1771
        	Error:      	"[]" should have 12 item(s), but has 0
    sandbox_list_test.go:330: 
        	Error Trace:	.../api/sandboxes/sandbox_list_test.go:330
        	Error:      	Condition never satisfied
        	Test:       	TestSandboxListPaginationRunningLargerLimit
--- FAIL: TestSandboxListPaginationRunningLargerLimit (99.36s)
github.com/e2b-dev/infra/tests/integration/internal/tests/orchestrator::TestSandboxMemoryIntegrity

Flake rate in main: 53.11% (Passed 989 times, Failed 1120 times)

Stack Traces | 64.8s run time
=== RUN   TestSandboxMemoryIntegrity
=== PAUSE TestSandboxMemoryIntegrity
=== CONT  TestSandboxMemoryIntegrity
    sandbox_memory_integrity_test.go:27: Build completed successfully
--- FAIL: TestSandboxMemoryIntegrity (64.75s)
github.com/e2b-dev/infra/tests/integration/internal/tests/orchestrator::TestSandboxMemoryIntegrity/tmpfs_hash

Flake rate in main: 53.11% (Passed 979 times, Failed 1109 times)

Stack Traces | 198s run time
=== RUN   TestSandboxMemoryIntegrity/tmpfs_hash
=== PAUSE TestSandboxMemoryIntegrity/tmpfs_hash
=== CONT  TestSandboxMemoryIntegrity/tmpfs_hash
    sandbox_memory_integrity_test.go:70: Command [bash] output: event:{start:{pid:1278}}
Executing command bash in sandbox ihr6v65jd2hdspcrrn9h8 (user: root)
    sandbox_memory_integrity_test.go:70: Command [bash] output: event:{data:{stdout:"Total memory: 985 MB\nUsed memory before tmpfs mount: 188 MB\nFree memory before tmpfs mount: 796 MB\nMemory to use in integrity test (60% of free, min 64MB): 477 MB\n"}}
    sandbox_memory_integrity_test.go:70: Command [bash] output: event:{data:{stderr:"477+0 records in\n477+0 records out\n500170752 bytes (500 MB, 477 MiB) copied, 2.29236 s, 218 MB/s\n"}}
    sandbox_memory_integrity_test.go:70: Command [bash] output: event:{data:{stderr:"\tCommand being timed: \"dd if=/dev/urandom of=/mnt/testfile bs=1M count=477\"\n\tUser time (seconds): 0.00\n\tSystem time (seconds): 2.28\n\tPercent of CPU this job got: 99%\n\tElapsed (wall clock) time (h:mm:ss or m:ss): 0:02.30\n\tAverage shared text size (kbytes): 0\n\tAverage unshared data size (kbytes): 0\n\tAverage stack size (kbytes): 0\n\tAverage total size (kbytes): 0\n\tMaximum resident set size (kbytes): 2688\n\tAverage resident set size (kbytes): 0\n\tMajor (requiring I/O) page faults: 3\n\tMinor (reclaiming a frame) page faults: 343\n\tVoluntary context switches: 4\n\tInvoluntary context switches: 27\n\tSwaps: 0\n\tFile system inputs: 176\n\tFile system outputs: 0\n\tSocket messages sent: 0\n\tSocket messages received: 0\n\tSignals delivered: 0\n\tPage size (bytes): 4096\n\tExit status: 0\n"}}
    sandbox_memory_integrity_test.go:70: Command [bash] output: event:{data:{stdout:"Used memory after tmpfs mount and file fill: 670 MB\n"}}
    sandbox_memory_integrity_test.go:70: Command [bash] output: event:{end:{exited:true  status:"exit status 0"}}
    sandbox_memory_integrity_test.go:70: Command [bash] completed successfully in sandbox iaoupg2iz2evpmagwa2uu
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
    sandbox_memory_integrity_test.go:80: Command [bash] output: event:{start:{pid:1294}}
Executing command bash in sandbox ivxhd3urlicf56q095i84 (user: root)
    sandbox_memory_integrity_test.go:80: Command [bash] output: event:{data:{stdout:"9d7ad7a8fff91296726aad520e6abf4d86bfc42f6eae7176e3a4b28cc2baf29b\n"}}
    sandbox_memory_integrity_test.go:80: Command [bash] output: event:{end:{exited:true  status:"exit status 0"}}
    sandbox_memory_integrity_test.go:80: Command [bash] completed successfully in sandbox iaoupg2iz2evpmagwa2uu
    sandbox_memory_integrity_test.go:80: Command [bash] output: event:{start:{pid:1297}}
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
Executing command bash in sandbox iaoupg2iz2evpmagwa2uu (user: root)
    sandbox_memory_integrity_test.go:110: 
        	Error Trace:	.../tests/orchestrator/sandbox_memory_integrity_test.go:81
        	            				.../hostedtoolcache/go/1.26.3.../src/runtime/asm_amd64.s:1771
        	Error:      	Received unexpected error:
        	            	failed to execute command bash in sandbox iaoupg2iz2evpmagwa2uu: unavailable: HTTP status 502 Bad Gateway
    sandbox_memory_integrity_test.go:110: 
        	Error Trace:	.../tests/orchestrator/sandbox_memory_integrity_test.go:78
        	            				.../tests/orchestrator/sandbox_memory_integrity_test.go:110
        	Error:      	Condition never satisfied
        	Test:       	TestSandboxMemoryIntegrity/tmpfs_hash
--- FAIL: TestSandboxMemoryIntegrity/tmpfs_hash (198.08s)
github.com/e2b-dev/infra/tests/integration/internal/tests/proxies::TestSandboxAutoResumeViaProxy

Flake rate in main: 38.56% (Passed 983 times, Failed 617 times)

Stack Traces | 21.9s run time
=== RUN   TestSandboxAutoResumeViaProxy
=== PAUSE TestSandboxAutoResumeViaProxy
=== CONT  TestSandboxAutoResumeViaProxy
    auto_resume_test.go:97: [Status code: 502] Response body: {"sandboxId":"ilixm8kl8ud3zyno1sj1x","message":"The sandbox is running but port is not open","port":8000,"code":502}
    auto_resume_test.go:116: 
        	Error Trace:	.../tests/proxies/auto_resume_test.go:116
        	Error:      	Received unexpected error:
        	            	Get "http://localhost:3002": context deadline exceeded (Client.Timeout exceeded while awaiting headers)
        	Test:       	TestSandboxAutoResumeViaProxy
--- FAIL: TestSandboxAutoResumeViaProxy (21.86s)

To view more test analytics, go to the Test Analytics Dashboard
📋 Got 3 mins? Take this short survey to help us improve Test Analytics.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

In packages/envd/internal/api/tunnel.go, the keepalive ping is sent using the request context without a timeout, which can cause the ping to block indefinitely if the connection becomes silently dead. Additionally, if the ping fails, the goroutine exits without closing the connection, leading to a silent socket and goroutine leak. Wrapping the ping in a short timeout context and explicitly closing the connection on failure ensures resources are cleaned up promptly.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread packages/envd/internal/api/tunnel.go

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 1143e5a. Configure here.

Comment thread packages/envd/internal/api/tunnel.go
mishushakov and others added 2 commits June 6, 2026 15:16
Bound each keepalive ping with a 5s timeout so a silently-dead connection
can't block the ping indefinitely, and close the WebSocket when a ping fails
so the relay's io.Copy calls unblock and the tunnel tears down promptly
instead of lingering until an upstream proxy idle timeout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@ValentaTomas

Copy link
Copy Markdown
Member

@mishushakov Can you check how big the websocket library is, just to be sure?

@mishushakov

Copy link
Copy Markdown
Member Author

@ValentaTomas according to Claude:

The WebSocket library used in envd is github.com/coder/websocket v1.8.13.

Size

Metric Value
On-disk module (incl. tests, docs) 344 KB
Non-test Go source ~4,470 lines across 29 files
External dependencies None

The vendor/golang.org/x/... entries in the dependency list are part of Go's own standard library (vendored into net/http, crypto, etc.), not dependencies of the websocket package itself. coder/websocket (formerly nhooyr.io/websocket) is notably dependency-free — it's a minimal, idiomatic implementation that builds directly on the stdlib.

So it's a very lightweight addition: a small, self-contained library with no transitive third-party dependencies pulled into the envd build.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants