diff --git a/.changeset/https-backend-ports.md b/.changeset/https-backend-ports.md new file mode 100644 index 0000000000..f7c9a45aeb --- /dev/null +++ b/.changeset/https-backend-ports.md @@ -0,0 +1,6 @@ +--- +"e2b": minor +"@e2b/python-sdk": minor +--- + +Add `httpsPorts` (JS) / `https_ports` (Python) to the sandbox network config. Ports listed there have their public URLs proxied to the sandbox over HTTPS — use it when the service listening on the port serves TLS itself. This is not TLS passthrough: traffic is still terminated at the E2B proxy and re-encrypted on the hop to the sandbox, and the backend certificate is not verified, so self-signed certificates work. The configured ports are also returned in the sandbox info network config. diff --git a/packages/js-sdk/src/api/schema.gen.ts b/packages/js-sdk/src/api/schema.gen.ts index c6f357d03e..2ff3e122f0 100644 --- a/packages/js-sdk/src/api/schema.gen.ts +++ b/packages/js-sdk/src/api/schema.gen.ts @@ -2350,6 +2350,8 @@ export interface components { allowPublicTraffic?: boolean; /** @description List of denied CIDR blocks or IP addresses for egress traffic. Domain names are not supported for deny rules. */ denyOut?: string[]; + /** @description Ports whose public URLs should connect to the sandbox using HTTPS. Backend certificate verification is disabled. */ + httpsPorts?: number[]; /** @description Specify host mask which will be used for all sandbox requests */ maskRequestHost?: string; /** @description Per-domain transform rules applied to matching egress HTTP/HTTPS requests. Keys are domains (e.g. "api.example.com", "example.com"). A domain listed here is not automatically allowed - use allowOut to permit the traffic. diff --git a/packages/js-sdk/src/sandbox/sandboxApi.ts b/packages/js-sdk/src/sandbox/sandboxApi.ts index 4cedebcfa5..c9b71fbdfd 100644 --- a/packages/js-sdk/src/sandbox/sandboxApi.ts +++ b/packages/js-sdk/src/sandbox/sandboxApi.ts @@ -164,6 +164,21 @@ export type SandboxNetworkOpts = { * @default ${PORT}-sandboxid.e2b.app */ maskRequestHost?: string + + /** + * Ports whose public URLs should connect to the sandbox using HTTPS. + * + * Use this when the service listening on the port serves HTTPS (TLS) + * itself. This is not TLS passthrough — traffic is still terminated at the + * E2B proxy and re-encrypted on the hop to the sandbox. The backend + * certificate is not verified, so self-signed certificates work. + * + * @example + * ```ts + * await Sandbox.create({ network: { httpsPorts: [3000] } }) + * ``` + */ + httpsPorts?: number[] } /** @@ -177,6 +192,7 @@ export type SandboxNetworkInfo = { rules?: Record allowPublicTraffic?: boolean maskRequestHost?: string + httpsPorts?: number[] } /** @@ -687,6 +703,9 @@ function buildNetworkBody( ...(network.maskRequestHost !== undefined ? { maskRequestHost: network.maskRequestHost } : {}), + ...(network.httpsPorts !== undefined + ? { httpsPorts: network.httpsPorts } + : {}), } } @@ -801,6 +820,7 @@ export class SandboxApi { rules: res.data.network.rules ?? undefined, allowPublicTraffic: res.data.network.allowPublicTraffic, maskRequestHost: res.data.network.maskRequestHost, + httpsPorts: res.data.network.httpsPorts, } : undefined, lifecycle: res.data.lifecycle diff --git a/packages/js-sdk/tests/sandbox/network.test.ts b/packages/js-sdk/tests/sandbox/network.test.ts index 2da46ffd66..5280e6ba8a 100644 --- a/packages/js-sdk/tests/sandbox/network.test.ts +++ b/packages/js-sdk/tests/sandbox/network.test.ts @@ -300,6 +300,67 @@ describe('updateNetwork clears existing rules when fields are omitted', () => { ) }) +describe('httpsPorts option', () => { + const port = 8443 + + sandboxTest.scoped({ + sandboxOpts: { + network: { + httpsPorts: [port], + }, + }, + }) + + sandboxTest.skipIf(isDebug)( + 'public URL proxies to an HTTPS backend in the sandbox', + async ({ sandbox }) => { + // Generate a self-signed certificate inside the sandbox + const keygen = await sandbox.commands.run( + 'openssl req -x509 -newkey rsa:2048 -keyout /tmp/https-backend-key.pem -out /tmp/https-backend-cert.pem -days 1 -nodes -subj "/CN=localhost"' + ) + assert.equal(keygen.exitCode, 0) + + // Start an HTTPS server on the configured port + sandbox.commands.run( + `python3 -c " +import http.server, ssl +class H(http.server.BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(200) + self.end_headers() + self.wfile.write(b'https backend') + def log_message(self, *a): pass +server = http.server.ThreadingHTTPServer(('0.0.0.0', ${port}), H) +context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) +context.load_cert_chain('/tmp/https-backend-cert.pem', '/tmp/https-backend-key.pem') +server.socket = context.wrap_socket(server.socket, server_side=True) +server.serve_forever() +"`, + { background: true } + ) + + // Poll the public URL until the server responds + const sandboxUrl = `https://${sandbox.getHost(port)}` + let response: Response | undefined + const deadline = Date.now() + 15_000 + while (Date.now() < deadline) { + response = await fetch(sandboxUrl) + if (response.status === 200) { + break + } + await new Promise((resolve) => setTimeout(resolve, 1000)) + } + + assert.equal(response?.status, 200) + assert.equal(await response?.text(), 'https backend') + + // The port is reported back in the sandbox info + const info = await sandbox.getInfo() + assert.deepEqual(info.network?.httpsPorts, [port]) + } + ) +}) + describe('maskRequestHost option', () => { sandboxTest.scoped({ sandboxOpts: { diff --git a/packages/python-sdk/e2b/api/client/models/sandbox_network_config.py b/packages/python-sdk/e2b/api/client/models/sandbox_network_config.py index c7a99c127b..495841c288 100644 --- a/packages/python-sdk/e2b/api/client/models/sandbox_network_config.py +++ b/packages/python-sdk/e2b/api/client/models/sandbox_network_config.py @@ -24,6 +24,8 @@ class SandboxNetworkConfig: authentication. Default: True. deny_out (Union[Unset, list[str]]): List of denied CIDR blocks or IP addresses for egress traffic. Domain names are not supported for deny rules. + https_ports (Union[Unset, list[int]]): Ports whose public URLs should connect to the sandbox using HTTPS. + Backend certificate verification is disabled. mask_request_host (Union[Unset, str]): Specify host mask which will be used for all sandbox requests rules (Union[Unset, SandboxNetworkConfigRules]): Per-domain transform rules applied to matching egress HTTP/HTTPS requests. Keys are domains (e.g. "api.example.com", "example.com"). A domain listed here is not @@ -33,6 +35,7 @@ class SandboxNetworkConfig: allow_out: Union[Unset, list[str]] = UNSET allow_public_traffic: Union[Unset, bool] = True deny_out: Union[Unset, list[str]] = UNSET + https_ports: Union[Unset, list[int]] = UNSET mask_request_host: Union[Unset, str] = UNSET rules: Union[Unset, "SandboxNetworkConfigRules"] = UNSET additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) @@ -48,6 +51,10 @@ def to_dict(self) -> dict[str, Any]: if not isinstance(self.deny_out, Unset): deny_out = self.deny_out + https_ports: Union[Unset, list[int]] = UNSET + if not isinstance(self.https_ports, Unset): + https_ports = self.https_ports + mask_request_host = self.mask_request_host rules: Union[Unset, dict[str, Any]] = UNSET @@ -63,6 +70,8 @@ def to_dict(self) -> dict[str, Any]: field_dict["allowPublicTraffic"] = allow_public_traffic if deny_out is not UNSET: field_dict["denyOut"] = deny_out + if https_ports is not UNSET: + field_dict["httpsPorts"] = https_ports if mask_request_host is not UNSET: field_dict["maskRequestHost"] = mask_request_host if rules is not UNSET: @@ -81,6 +90,8 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: deny_out = cast(list[str], d.pop("denyOut", UNSET)) + https_ports = cast(list[int], d.pop("httpsPorts", UNSET)) + mask_request_host = d.pop("maskRequestHost", UNSET) _rules = d.pop("rules", UNSET) @@ -94,6 +105,7 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: allow_out=allow_out, allow_public_traffic=allow_public_traffic, deny_out=deny_out, + https_ports=https_ports, mask_request_host=mask_request_host, rules=rules, ) diff --git a/packages/python-sdk/e2b/sandbox/sandbox_api.py b/packages/python-sdk/e2b/sandbox/sandbox_api.py index d0982864f2..4c3c38b462 100644 --- a/packages/python-sdk/e2b/sandbox/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox/sandbox_api.py @@ -202,6 +202,18 @@ class SandboxNetworkOpts(TypedDict): - Custom subdomain: `"${PORT}-myapp.example.com"` """ + https_ports: NotRequired[List[int]] + """ + Ports whose public URLs should connect to the sandbox using HTTPS. + + Use this when the service listening on the port serves HTTPS (TLS) itself. + This is not TLS passthrough — traffic is still terminated at the E2B proxy + and re-encrypted on the hop to the sandbox. The backend certificate is not + verified, so self-signed certificates work. + + Example: ``Sandbox.create(network={"https_ports": [3000]})`` + """ + class SandboxNetworkUpdate(TypedDict, total=False): """ @@ -238,6 +250,7 @@ class SandboxNetworkInfo(TypedDict, total=False): rules: Dict[str, List[SandboxNetworkRuleInfo]] allow_public_traffic: bool mask_request_host: str + https_ports: List[int] class SandboxOnTimeoutPause(TypedDict): @@ -402,6 +415,8 @@ def build_network_config( body["allow_public_traffic"] = network["allow_public_traffic"] if "mask_request_host" in network: body["mask_request_host"] = network["mask_request_host"] + if "https_ports" in network: + body["https_ports"] = list(network["https_ports"]) return body @@ -447,6 +462,8 @@ def from_client_network_config( result["allow_public_traffic"] = network.allow_public_traffic if not isinstance(network.mask_request_host, Unset): result["mask_request_host"] = network.mask_request_host + if not isinstance(network.https_ports, Unset): + result["https_ports"] = list(network.https_ports) return result diff --git a/packages/python-sdk/tests/async/sandbox_async/test_network.py b/packages/python-sdk/tests/async/sandbox_async/test_network.py index 9e8aa50869..078e98d9f7 100644 --- a/packages/python-sdk/tests/async/sandbox_async/test_network.py +++ b/packages/python-sdk/tests/async/sandbox_async/test_network.py @@ -262,6 +262,54 @@ async def test_update_network_clears_existing_rules(async_sandbox_factory): assert r2.exit_code == 0 +@pytest.mark.skip_debug() +async def test_https_ports(async_sandbox_factory): + """Test that a port listed in https_ports proxies to an HTTPS backend.""" + port = 8443 + async_sandbox = await async_sandbox_factory( + network=SandboxNetworkOpts(https_ports=[port]) + ) + + # Generate a self-signed certificate inside the sandbox + keygen = await async_sandbox.commands.run( + "openssl req -x509 -newkey rsa:2048 " + "-keyout /tmp/https-backend-key.pem -out /tmp/https-backend-cert.pem " + '-days 1 -nodes -subj "/CN=localhost"' + ) + assert keygen.exit_code == 0 + + # Start an HTTPS server on the configured port + await async_sandbox.commands.run( + f"""python3 -c " +import http.server, ssl +class H(http.server.BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(200) + self.end_headers() + self.wfile.write(b'https backend') + def log_message(self, *a): pass +server = http.server.ThreadingHTTPServer(('0.0.0.0', {port}), H) +context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) +context.load_cert_chain('/tmp/https-backend-cert.pem', '/tmp/https-backend-key.pem') +server.socket = context.wrap_socket(server.socket, server_side=True) +server.serve_forever() +" """, + background=True, + ) + + sandbox_url = f"https://{async_sandbox.get_host(port)}" + + async with httpx.AsyncClient() as client: + response = await wait_for_status(client, sandbox_url, 200) + assert response.status_code == 200 + assert response.text == "https backend" + + # The port is reported back in the sandbox info + info = await async_sandbox.get_info() + assert info.network is not None + assert info.network.get("https_ports") == [port] + + @pytest.mark.skip_debug() async def test_mask_request_host(async_sandbox_factory): """Test that mask_request_host modifies the Host header correctly.""" diff --git a/packages/python-sdk/tests/sync/sandbox_sync/test_network.py b/packages/python-sdk/tests/sync/sandbox_sync/test_network.py index a921730d93..0b2c120022 100644 --- a/packages/python-sdk/tests/sync/sandbox_sync/test_network.py +++ b/packages/python-sdk/tests/sync/sandbox_sync/test_network.py @@ -257,6 +257,52 @@ def test_update_network_clears_existing_rules(sandbox_factory): assert r2.exit_code == 0 +@pytest.mark.skip_debug() +def test_https_ports(sandbox_factory): + """Test that a port listed in https_ports proxies to an HTTPS backend.""" + port = 8443 + sandbox = sandbox_factory(network=SandboxNetworkOpts(https_ports=[port])) + + # Generate a self-signed certificate inside the sandbox + keygen = sandbox.commands.run( + "openssl req -x509 -newkey rsa:2048 " + "-keyout /tmp/https-backend-key.pem -out /tmp/https-backend-cert.pem " + '-days 1 -nodes -subj "/CN=localhost"' + ) + assert keygen.exit_code == 0 + + # Start an HTTPS server on the configured port + sandbox.commands.run( + f"""python3 -c " +import http.server, ssl +class H(http.server.BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(200) + self.end_headers() + self.wfile.write(b'https backend') + def log_message(self, *a): pass +server = http.server.ThreadingHTTPServer(('0.0.0.0', {port}), H) +context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) +context.load_cert_chain('/tmp/https-backend-cert.pem', '/tmp/https-backend-key.pem') +server.socket = context.wrap_socket(server.socket, server_side=True) +server.serve_forever() +" """, + background=True, + ) + + sandbox_url = f"https://{sandbox.get_host(port)}" + + with httpx.Client() as client: + response = wait_for_status(client, sandbox_url, 200) + assert response.status_code == 200 + assert response.text == "https backend" + + # The port is reported back in the sandbox info + info = sandbox.get_info() + assert info.network is not None + assert info.network.get("https_ports") == [port] + + @pytest.mark.skip_debug() def test_mask_request_host(sandbox_factory): """Test that mask_request_host modifies the Host header correctly.""" diff --git a/spec/openapi.yml b/spec/openapi.yml index 0f20f8d70c..45da7af96d 100644 --- a/spec/openapi.yml +++ b/spec/openapi.yml @@ -295,6 +295,16 @@ components: maskRequestHost: type: string description: Specify host mask which will be used for all sandbox requests + httpsPorts: + type: array + description: Ports whose public URLs should connect to the sandbox using HTTPS. Backend certificate verification is disabled. + maxItems: 128 + uniqueItems: true + items: + type: integer + format: uint32 + minimum: 1 + maximum: 65535 rules: type: object description: >