diff --git a/.changeset/egress-proxy-tls.md b/.changeset/egress-proxy-tls.md new file mode 100644 index 0000000000..d5f20b302b --- /dev/null +++ b/.changeset/egress-proxy-tls.md @@ -0,0 +1,6 @@ +--- +"e2b": minor +"@e2b/python-sdk": minor +--- + +Add `tls` to the egress proxy options as `true` or `{ serverName?, caCert? }` (`server_name`/`ca_cert` in Python); `getInfo`/`get_info` reports it without the CA. diff --git a/packages/js-sdk/src/api/schema.gen.ts b/packages/js-sdk/src/api/schema.gen.ts index cb39bcc138..31285a5ca6 100644 --- a/packages/js-sdk/src/api/schema.gen.ts +++ b/packages/js-sdk/src/api/schema.gen.ts @@ -2525,9 +2525,19 @@ export interface components { address: string; /** @description Optional SOCKS5 password (RFC 1929), max 255 bytes. */ password?: string; + tls?: components["schemas"]["SandboxEgressProxyTLSConfig"]; /** @description Optional SOCKS5 username (RFC 1929), max 255 bytes. */ username?: string; } | null; + /** @description TLS for the connection to the SOCKS5 proxy. The SOCKS5 negotiation and the tunneled traffic both run inside the TLS session, so the proxy credentials are not sent in the clear. This secures only the hop to the proxy; what the proxy does onward is its own concern. A half-close from the sandbox reaches the proxy as a TLS close_notify, not a TCP FIN, and a proxy that treats close_notify as a full close cuts the reply short. */ + SandboxEgressProxyTLSConfig: { + /** @description One or more PEM-encoded certificates to verify the proxy against, for a proxy fronted by a private CA. These replace the system trust store, which is what is used when this is omitted. The system trust store depends on the host the orchestrator runs on, so set this to get the same verification everywhere. */ + caCert?: string; + /** @description Connect to the proxy over TLS. When false, no other field in this object may be set. */ + enabled: boolean; + /** @description Name to verify the proxy certificate against, and to send as SNI. Defaults to the host part of address. Set this only when the certificate does not match the address the proxy is reached at. */ + serverName?: string; + } | null; SandboxesWithMetrics: { sandboxes: { [key: string]: components["schemas"]["SandboxMetric"]; @@ -3015,7 +3025,7 @@ export interface components { * @default [] */ args?: string[]; - /** @description Hash of the files used in the step */ + /** @description Hash of the files used in the step (lowercase hex SHA-256) */ filesHash?: string; /** * @description Whether the step should be forced to run regardless of the cache diff --git a/packages/js-sdk/src/envd/schema.gen.ts b/packages/js-sdk/src/envd/schema.gen.ts index c00443dc6e..74e72ca420 100644 --- a/packages/js-sdk/src/envd/schema.gen.ts +++ b/packages/js-sdk/src/envd/schema.gen.ts @@ -283,13 +283,24 @@ export interface components { }; /** @description Resource usage metrics */ Metrics: { - /** @description Number of CPU cores */ + /** @description Number of CPU cores online */ cpu_count?: number; + /** @description Number of CPUs the guest can bring online */ + cpu_possible?: number; + /** @description Online count last requested, 0 when none was */ + cpu_target?: number; + /** @description Attempts at reaching cpu_target since it was set */ + cpu_target_attempts?: number; /** * Format: float * @description CPU usage percentage */ cpu_used_pct?: number; + /** + * Format: int64 + * @description How long a CPU online or offline write has been running in the guest kernel, 0 when none is. A value that keeps growing means CPU hotplug is stuck. + */ + cpu_write_pending_ms?: number; /** @description Total disk space in bytes */ disk_total?: number; /** @description Used disk space in bytes */ diff --git a/packages/js-sdk/src/index.ts b/packages/js-sdk/src/index.ts index 55a74e6af3..5de45f1226 100644 --- a/packages/js-sdk/src/index.ts +++ b/packages/js-sdk/src/index.ts @@ -76,7 +76,9 @@ export type { SandboxIamToken, SandboxIamTokenType, SandboxEgressProxyOpts, + SandboxEgressProxyTLSOpts, SandboxEgressProxyInfo, + SandboxEgressProxyTLSInfo, SandboxNetworkOpts, SandboxNetworkInfo, SandboxNetworkSelector, diff --git a/packages/js-sdk/src/sandbox/sandboxApi.ts b/packages/js-sdk/src/sandbox/sandboxApi.ts index 50872b4881..a7025be4c3 100644 --- a/packages/js-sdk/src/sandbox/sandboxApi.ts +++ b/packages/js-sdk/src/sandbox/sandboxApi.ts @@ -167,6 +167,7 @@ export type SandboxNetworkSelector = * address: 'proxy.example.com:1080', * username: 'proxy-user', * password: 'proxy-password', + * tls: true, * }, * }, * }) @@ -198,18 +199,59 @@ export type SandboxEgressProxyOpts = { * {@link SandboxEgressProxyOpts.username}. */ password?: string + + /** + * Connect to the proxy over TLS. The SOCKS5 handshake, credentials, and + * tunneled traffic run inside the TLS session; TLS protects only the hop to + * the proxy. The certificate is always verified, TLS 1.2 or newer is + * required, and failures never fall back to plaintext. + * + * Set to `true` to use the system trust store and the host part of `address` + * for SNI. Passing an object also enables TLS, with optional custom + * `serverName` and `caCert`. + */ + tls?: boolean | SandboxEgressProxyTLSOpts +} + +/** + * TLS settings for the connection to a BYOP SOCKS5 proxy. + */ +export type SandboxEgressProxyTLSOpts = { + /** + * Name to verify the proxy certificate against and send as SNI, up to 253 + * characters. Defaults to the host part of `address`; set this only when the + * certificate does not match the address. + */ + serverName?: string + + /** + * One or more PEM CA certificates, up to 8192 bytes. This replaces the + * system trust store rather than adding to it. + */ + caCert?: string } /** * Egress proxy as returned by the sandbox info endpoint. Mirrors - * {@link SandboxEgressProxyOpts} without `password` — the API never returns - * it. + * {@link SandboxEgressProxyOpts} without `password` or `caCert` — the API + * never returns them. */ export type SandboxEgressProxyInfo = { /** See {@link SandboxEgressProxyOpts.address}. */ address: string /** See {@link SandboxEgressProxyOpts.username}. */ username?: string + /** Set when TLS to the proxy is on. */ + tls?: SandboxEgressProxyTLSInfo +} + +/** + * TLS status for the egress proxy connection. The CA certificate is never + * returned by the API. + */ +export type SandboxEgressProxyTLSInfo = { + /** See {@link SandboxEgressProxyTLSOpts.serverName}. */ + serverName?: string } export type SandboxNetworkOpts = { @@ -377,6 +419,9 @@ export type SandboxNetworkUpdate = { * traffic out directly — even when the update was only meant to change the * allow and deny lists. Repeat it in every update that should keep * tunneling. + * + * The same replace semantics apply to `tls`: an update that repeats + * `address` but omits `tls` keeps tunneling without TLS. */ egressProxy?: SandboxEgressProxyOpts /** @@ -1157,6 +1202,32 @@ function buildEgressProxyBody( ) } + const tls = egressProxy.tls + if (tls != null && typeof tls !== 'boolean' && !isPlainObject(tls)) { + throw new InvalidArgumentError( + 'network egressProxy tls must be a boolean or an object.' + ) + } + if ( + isPlainObject(tls) && + Object.prototype.hasOwnProperty.call(tls, 'enabled') + ) { + throw new InvalidArgumentError( + "network egressProxy tls takes no 'enabled'; pass tls: true or tls: false instead." + ) + } + + const tlsBody = + tls === true + ? { enabled: true } + : tls != null && typeof tls === 'object' + ? { + enabled: true, + ...(tls.serverName != null ? { serverName: tls.serverName } : {}), + ...(tls.caCert != null ? { caCert: tls.caCert } : {}), + } + : undefined + // `!= null` also skips a `null` credential, which is what reading one out of // an unset environment variable yields and what "this proxy takes no // credentials" means; the API only accepts a string. @@ -1164,6 +1235,7 @@ function buildEgressProxyBody( address: egressProxy.address, ...(egressProxy.username != null ? { username: egressProxy.username } : {}), ...(egressProxy.password != null ? { password: egressProxy.password } : {}), + ...(tlsBody !== undefined ? { tls: tlsBody } : {}), } } @@ -1222,6 +1294,15 @@ function fromApiEgressProxy( ...(egressProxy.username !== undefined ? { username: egressProxy.username } : {}), + ...(egressProxy.tls?.enabled === true + ? { + tls: { + ...(egressProxy.tls.serverName !== undefined + ? { serverName: egressProxy.tls.serverName } + : {}), + }, + } + : {}), } } diff --git a/packages/js-sdk/tests/sandbox/egressProxy.test.ts b/packages/js-sdk/tests/sandbox/egressProxy.test.ts index e4c137055a..15bd76e3fa 100644 --- a/packages/js-sdk/tests/sandbox/egressProxy.test.ts +++ b/packages/js-sdk/tests/sandbox/egressProxy.test.ts @@ -2,6 +2,7 @@ import { afterAll, afterEach, beforeAll, expect, test } from 'vitest' import { http, HttpResponse } from 'msw' import { Sandbox } from '../../src' +import { InvalidArgumentError } from '../../src/errors' import { TEST_API_KEY, apiUrl } from '../setup' import { setupMockApi } from '../mockApi' @@ -84,6 +85,95 @@ test('Sandbox.create sends an address-only egress proxy without credentials', as }) }) +test('Sandbox.create sends egress proxy TLS options and strips unknown fields', async () => { + await Sandbox.create('base', { + apiKey: TEST_API_KEY, + network: { + egressProxy: { + address: 'proxy.example.com:1080', + tls: { + serverName: 'proxy.example.com', + caCert: '-----BEGIN CERTIFICATE-----', + ignored: 'not sent', + } as never, + }, + }, + }) + + expect(lastCreateBody?.network.egressProxy).toEqual({ + address: 'proxy.example.com:1080', + tls: { + enabled: true, + serverName: 'proxy.example.com', + caCert: '-----BEGIN CERTIFICATE-----', + }, + }) +}) + +test.for([ + ['true', true, { enabled: true }], + ['an empty options object', {}, { enabled: true }], +])( + 'Sandbox.create sends TLS settings when %s is passed', + async ([, tls, wireTls]: [string, unknown, object]) => { + await Sandbox.create('base', { + apiKey: TEST_API_KEY, + network: { + egressProxy: { + address: 'proxy.example.com:1080', + tls: tls as never, + }, + }, + }) + + expect(lastCreateBody?.network.egressProxy).toEqual({ + address: 'proxy.example.com:1080', + tls: wireTls, + }) + } +) + +test('Sandbox.create omits null TLS options', async () => { + await Sandbox.create('base', { + apiKey: TEST_API_KEY, + network: { + egressProxy: { + address: 'proxy.example.com:1080', + tls: { + serverName: null, + caCert: null, + ignored: 'not sent', + } as never, + }, + }, + }) + + expect(lastCreateBody?.network.egressProxy).toEqual({ + address: 'proxy.example.com:1080', + tls: { enabled: true }, + }) +}) + +test.for([ + ['false', false], + ['null', null], +])( + 'Sandbox.create omits %s TLS options', + async ([, tls]: [string, unknown]) => { + await Sandbox.create('base', { + apiKey: TEST_API_KEY, + network: { + egressProxy: { + address: 'proxy.example.com:1080', + tls: tls as never, + }, + }, + }) + + expect(lastCreateBody?.network.egressProxy).not.toHaveProperty('tls') + } +) + test('Sandbox.create combines the egress proxy with allow and deny lists', async () => { await Sandbox.create('base', { apiKey: TEST_API_KEY, @@ -148,6 +238,60 @@ test.for([ } ) +test.for([ + [ + 'a string', + 'tls', + 'network egressProxy tls must be a boolean or an object.', + ], + ['an array', [], 'network egressProxy tls must be a boolean or an object.'], +])( + 'Sandbox.create rejects %s as egress proxy TLS settings', + async ([, tls, message]: [string, unknown, string]) => { + const error = await Sandbox.create('base', { + apiKey: TEST_API_KEY, + network: { + egressProxy: { + address: 'proxy.example.com:1080', + tls: tls as never, + }, + }, + }).catch((error: unknown) => error) + + expect(error).toBeInstanceOf(InvalidArgumentError) + expect(error).toHaveProperty('message', message) + } +) + +test.for([ + [ + 'enabled true', + { enabled: true }, + "network egressProxy tls takes no 'enabled'; pass tls: true or tls: false instead.", + ], + [ + 'enabled false', + { enabled: false }, + "network egressProxy tls takes no 'enabled'; pass tls: true or tls: false instead.", + ], +])( + 'Sandbox.create rejects TLS options with %s', + async ([, tls, message]: [string, unknown, string]) => { + const error = await Sandbox.create('base', { + apiKey: TEST_API_KEY, + network: { + egressProxy: { + address: 'proxy.example.com:1080', + tls: tls as never, + }, + }, + }).catch((error: unknown) => error) + + expect(error).toBeInstanceOf(InvalidArgumentError) + expect(error).toHaveProperty('message', message) + } +) + test('Sandbox.create omits null credentials', async () => { // Reading a credential out of an unset environment variable is how a caller // lands here, and it means the proxy takes no credentials — the API only @@ -199,6 +343,86 @@ test('updateNetwork sets the egress proxy on a running sandbox', async () => { }) }) +test('updateNetwork sends egress proxy TLS options', async () => { + await Sandbox.updateNetwork( + sandboxId, + { + egressProxy: { + address: 'proxy.example.com:1080', + tls: { + serverName: 'proxy.example.com', + caCert: '-----BEGIN CERTIFICATE-----', + ignored: 'not sent', + } as never, + }, + }, + { apiKey: TEST_API_KEY } + ) + + expect(lastUpdateBody).toEqual({ + egressProxy: { + address: 'proxy.example.com:1080', + tls: { + enabled: true, + serverName: 'proxy.example.com', + caCert: '-----BEGIN CERTIFICATE-----', + }, + }, + }) +}) + +test('updateNetwork sends true TLS settings as enabled', async () => { + await Sandbox.updateNetwork( + sandboxId, + { + egressProxy: { + address: 'proxy.example.com:1080', + tls: true, + }, + }, + { apiKey: TEST_API_KEY } + ) + + expect(lastUpdateBody?.egressProxy.tls).toEqual({ enabled: true }) +}) + +test('updateNetwork rejects TLS options with an enabled key', async () => { + const error = await Sandbox.updateNetwork( + sandboxId, + { + egressProxy: { + address: 'proxy.example.com:1080', + tls: { enabled: false } as never, + }, + }, + { apiKey: TEST_API_KEY } + ).catch((error: unknown) => error) + + expect(error).toBeInstanceOf(InvalidArgumentError) + expect(error).toHaveProperty( + 'message', + "network egressProxy tls takes no 'enabled'; pass tls: true or tls: false instead." + ) +}) + +test.for([ + ['false', false], + ['null', null], +])('updateNetwork omits %s TLS options', async ([, tls]: [string, unknown]) => { + await Sandbox.updateNetwork( + sandboxId, + { + egressProxy: { + address: 'proxy.example.com:1080', + tls: tls as never, + }, + }, + { apiKey: TEST_API_KEY } + ) + + expect(lastUpdateBody?.egressProxy).not.toHaveProperty('tls') +}) + test('an update without the egress proxy clears it', async () => { // The update replaces the whole configuration instead of merging into it, so // omitting the proxy stops tunneling rather than leaving it in place. @@ -221,6 +445,41 @@ test('getInfo reports the active egress proxy without the password', async () => }) }) +test('getInfo reports egress proxy TLS without the CA certificate', async () => { + sandboxNetwork = { + egressProxy: { + address: 'proxy.example.com:1080', + tls: { + enabled: true, + serverName: 'proxy.example.com', + caCert: '-----BEGIN CERTIFICATE-----', + }, + }, + } + + const info = await Sandbox.getInfo(sandboxId, { apiKey: TEST_API_KEY }) + + expect(info.network?.egressProxy).toEqual({ + address: 'proxy.example.com:1080', + tls: { serverName: 'proxy.example.com' }, + }) +}) + +test('getInfo omits egress proxy TLS when it is disabled', async () => { + sandboxNetwork = { + egressProxy: { + address: 'proxy.example.com:1080', + tls: { enabled: false, serverName: 'proxy.example.com' }, + }, + } + + const info = await Sandbox.getInfo(sandboxId, { apiKey: TEST_API_KEY }) + + expect(info.network?.egressProxy).toEqual({ + address: 'proxy.example.com:1080', + }) +}) + test('getInfo drops a password the API unexpectedly returns', async () => { // SandboxEgressProxyInfo says the password is not there, so it must not be // there even if a future API version starts echoing it back. diff --git a/packages/python-sdk/e2b/__init__.py b/packages/python-sdk/e2b/__init__.py index b4f1d0984a..53830c87c2 100644 --- a/packages/python-sdk/e2b/__init__.py +++ b/packages/python-sdk/e2b/__init__.py @@ -85,6 +85,8 @@ McpServer, SandboxEgressProxyInfo, SandboxEgressProxyOpts, + SandboxEgressProxyTLSInfo, + SandboxEgressProxyTLSOpts, SandboxIamOpts, SandboxIamToken, SandboxIamTokenType, @@ -232,6 +234,8 @@ # Network "SandboxEgressProxyOpts", "SandboxEgressProxyInfo", + "SandboxEgressProxyTLSOpts", + "SandboxEgressProxyTLSInfo", "SandboxNetworkOpts", "SandboxNetworkInfo", "SandboxNetworkSelector", diff --git a/packages/python-sdk/e2b/api/client/api/templates/get_templates_template_id_files_hash.py b/packages/python-sdk/e2b/api/client/api/templates/get_templates_template_id_files_hash.py index 834de12cf7..74e72dad54 100644 --- a/packages/python-sdk/e2b/api/client/api/templates/get_templates_template_id_files_hash.py +++ b/packages/python-sdk/e2b/api/client/api/templates/get_templates_template_id_files_hash.py @@ -78,7 +78,7 @@ def sync_detailed( Args: template_id (str): - hash_ (str): Hash of the files + hash_ (str): Hash of the files (lowercase hex SHA-256) Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -112,7 +112,7 @@ def sync( Args: template_id (str): - hash_ (str): Hash of the files + hash_ (str): Hash of the files (lowercase hex SHA-256) Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -141,7 +141,7 @@ async def asyncio_detailed( Args: template_id (str): - hash_ (str): Hash of the files + hash_ (str): Hash of the files (lowercase hex SHA-256) Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -173,7 +173,7 @@ async def asyncio( Args: template_id (str): - hash_ (str): Hash of the files + hash_ (str): Hash of the files (lowercase hex SHA-256) Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. diff --git a/packages/python-sdk/e2b/api/client/models/__init__.py b/packages/python-sdk/e2b/api/client/models/__init__.py index 8aba151880..a2ab66b8ed 100644 --- a/packages/python-sdk/e2b/api/client/models/__init__.py +++ b/packages/python-sdk/e2b/api/client/models/__init__.py @@ -29,6 +29,7 @@ from .sandbox_auto_resume_config import SandboxAutoResumeConfig from .sandbox_detail import SandboxDetail from .sandbox_egress_proxy_config_type_0 import SandboxEgressProxyConfigType0 +from .sandbox_egress_proxy_tls_config_type_0 import SandboxEgressProxyTLSConfigType0 from .sandbox_fork_request import SandboxForkRequest from .sandbox_fork_result import SandboxForkResult from .sandbox_iam import SandboxIam @@ -110,6 +111,7 @@ "SandboxAutoResumeConfig", "SandboxDetail", "SandboxEgressProxyConfigType0", + "SandboxEgressProxyTLSConfigType0", "SandboxesWithMetrics", "SandboxForkRequest", "SandboxForkResult", diff --git a/packages/python-sdk/e2b/api/client/models/sandbox_egress_proxy_config_type_0.py b/packages/python-sdk/e2b/api/client/models/sandbox_egress_proxy_config_type_0.py index 8f5c32e33f..cc2dbdebb9 100644 --- a/packages/python-sdk/e2b/api/client/models/sandbox_egress_proxy_config_type_0.py +++ b/packages/python-sdk/e2b/api/client/models/sandbox_egress_proxy_config_type_0.py @@ -1,11 +1,17 @@ from collections.abc import Mapping -from typing import Any, TypeVar, Union +from typing import TYPE_CHECKING, Any, TypeVar, Union, cast from attrs import define as _attrs_define from attrs import field as _attrs_field from ..types import UNSET, Unset +if TYPE_CHECKING: + from ..models.sandbox_egress_proxy_tls_config_type_0 import ( + SandboxEgressProxyTLSConfigType0, + ) + + T = TypeVar("T", bound="SandboxEgressProxyConfigType0") @@ -18,20 +24,38 @@ class SandboxEgressProxyConfigType0: address (str): SOCKS5 proxy address in host:port format (e.g. "proxy.example.com:1080"). username (Union[Unset, str]): Optional SOCKS5 username (RFC 1929), max 255 bytes. password (Union[Unset, str]): Optional SOCKS5 password (RFC 1929), max 255 bytes. + tls (Union['SandboxEgressProxyTLSConfigType0', None, Unset]): TLS for the connection to the SOCKS5 proxy. The + SOCKS5 negotiation and the tunneled traffic both run inside the TLS session, so the proxy credentials are not + sent in the clear. This secures only the hop to the proxy; what the proxy does onward is its own concern. A + half-close from the sandbox reaches the proxy as a TLS close_notify, not a TCP FIN, and a proxy that treats + close_notify as a full close cuts the reply short. """ address: str username: Union[Unset, str] = UNSET password: Union[Unset, str] = UNSET + tls: Union["SandboxEgressProxyTLSConfigType0", None, Unset] = UNSET additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) def to_dict(self) -> dict[str, Any]: + from ..models.sandbox_egress_proxy_tls_config_type_0 import ( + SandboxEgressProxyTLSConfigType0, + ) + address = self.address username = self.username password = self.password + tls: Union[None, Unset, dict[str, Any]] + if isinstance(self.tls, Unset): + tls = UNSET + elif isinstance(self.tls, SandboxEgressProxyTLSConfigType0): + tls = self.tls.to_dict() + else: + tls = self.tls + field_dict: dict[str, Any] = {} field_dict.update(self.additional_properties) field_dict.update( @@ -43,11 +67,17 @@ def to_dict(self) -> dict[str, Any]: field_dict["username"] = username if password is not UNSET: field_dict["password"] = password + if tls is not UNSET: + field_dict["tls"] = tls return field_dict @classmethod def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.sandbox_egress_proxy_tls_config_type_0 import ( + SandboxEgressProxyTLSConfigType0, + ) + d = dict(src_dict) address = d.pop("address") @@ -55,10 +85,32 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: password = d.pop("password", UNSET) + def _parse_tls( + data: object, + ) -> Union["SandboxEgressProxyTLSConfigType0", None, Unset]: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + componentsschemas_sandbox_egress_proxy_tls_config_type_0 = ( + SandboxEgressProxyTLSConfigType0.from_dict(data) + ) + + return componentsschemas_sandbox_egress_proxy_tls_config_type_0 + except: # noqa: E722 + pass + return cast(Union["SandboxEgressProxyTLSConfigType0", None, Unset], data) + + tls = _parse_tls(d.pop("tls", UNSET)) + sandbox_egress_proxy_config_type_0 = cls( address=address, username=username, password=password, + tls=tls, ) sandbox_egress_proxy_config_type_0.additional_properties = d diff --git a/packages/python-sdk/e2b/api/client/models/sandbox_egress_proxy_tls_config_type_0.py b/packages/python-sdk/e2b/api/client/models/sandbox_egress_proxy_tls_config_type_0.py new file mode 100644 index 0000000000..7ed3c80264 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/sandbox_egress_proxy_tls_config_type_0.py @@ -0,0 +1,87 @@ +from collections.abc import Mapping +from typing import Any, TypeVar, Union + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="SandboxEgressProxyTLSConfigType0") + + +@_attrs_define +class SandboxEgressProxyTLSConfigType0: + """TLS for the connection to the SOCKS5 proxy. The SOCKS5 negotiation and the tunneled traffic both run inside the TLS + session, so the proxy credentials are not sent in the clear. This secures only the hop to the proxy; what the proxy + does onward is its own concern. A half-close from the sandbox reaches the proxy as a TLS close_notify, not a TCP + FIN, and a proxy that treats close_notify as a full close cuts the reply short. + + Attributes: + enabled (bool): Connect to the proxy over TLS. When false, no other field in this object may be set. + server_name (Union[Unset, str]): Name to verify the proxy certificate against, and to send as SNI. Defaults to + the host part of address. Set this only when the certificate does not match the address the proxy is reached at. + ca_cert (Union[Unset, str]): One or more PEM-encoded certificates to verify the proxy against, for a proxy + fronted by a private CA. These replace the system trust store, which is what is used when this is omitted. The + system trust store depends on the host the orchestrator runs on, so set this to get the same verification + everywhere. + """ + + enabled: bool + server_name: Union[Unset, str] = UNSET + ca_cert: Union[Unset, str] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + enabled = self.enabled + + server_name = self.server_name + + ca_cert = self.ca_cert + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "enabled": enabled, + } + ) + if server_name is not UNSET: + field_dict["serverName"] = server_name + if ca_cert is not UNSET: + field_dict["caCert"] = ca_cert + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + enabled = d.pop("enabled") + + server_name = d.pop("serverName", UNSET) + + ca_cert = d.pop("caCert", UNSET) + + sandbox_egress_proxy_tls_config_type_0 = cls( + enabled=enabled, + server_name=server_name, + ca_cert=ca_cert, + ) + + sandbox_egress_proxy_tls_config_type_0.additional_properties = d + return sandbox_egress_proxy_tls_config_type_0 + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/template_step.py b/packages/python-sdk/e2b/api/client/models/template_step.py index 45daaecd06..b2c13d7083 100644 --- a/packages/python-sdk/e2b/api/client/models/template_step.py +++ b/packages/python-sdk/e2b/api/client/models/template_step.py @@ -16,7 +16,7 @@ class TemplateStep: Attributes: type_ (str): Type of the step args (Union[Unset, list[str]]): Arguments for the step - files_hash (Union[Unset, str]): Hash of the files used in the step + files_hash (Union[Unset, str]): Hash of the files used in the step (lowercase hex SHA-256) force (Union[Unset, bool]): Whether the step should be forced to run regardless of the cache Default: False. """ diff --git a/packages/python-sdk/e2b/sandbox/sandbox_api.py b/packages/python-sdk/e2b/sandbox/sandbox_api.py index 7b75c8d3f3..aaff962885 100644 --- a/packages/python-sdk/e2b/sandbox/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox/sandbox_api.py @@ -31,6 +31,9 @@ from e2b.api.client.models import ( SandboxEgressProxyConfigType0 as ClientSandboxEgressProxyConfig, ) +from e2b.api.client.models import ( + SandboxEgressProxyTLSConfigType0 as ClientSandboxEgressProxyTLSConfig, +) from e2b.api.client.models import ( SandboxNetworkConfig as ClientSandboxNetworkConfig, ) @@ -260,6 +263,25 @@ class SandboxNetworkSelectorContext: """ +class SandboxEgressProxyTLSOpts(TypedDict, total=False): + """ + TLS settings for the connection to a BYOP SOCKS5 proxy. + """ + + server_name: str + """ + Name to verify the proxy certificate against and send as SNI, up to 253 + characters. Defaults to the host part of ``address``; set this only when + the certificate does not match the address. + """ + + ca_cert: str + """ + One or more PEM CA certificates, up to 8192 bytes. This replaces the + system trust store rather than adding to it. + """ + + class SandboxEgressProxyOpts(TypedDict): """ SOCKS5 proxy the sandbox's outbound TCP is tunneled through — "bring your @@ -286,6 +308,9 @@ class SandboxEgressProxyOpts(TypedDict): }, }, ) + + Set ``"tls": True`` to enable TLS with the system trust store, or pass a + mapping to set a custom server name or CA certificate. """ address: str @@ -313,12 +338,34 @@ class SandboxEgressProxyOpts(TypedDict): :attr:`username`. """ + tls: NotRequired[Union[bool, SandboxEgressProxyTLSOpts]] + """ + Connect to the proxy over TLS. The SOCKS5 handshake, credentials, and + tunneled traffic run inside the TLS session; TLS protects only the hop to + the proxy. The certificate is always verified, TLS 1.2 or newer is + required, and failures never fall back to plaintext. + + Set to ``True`` to use the system trust store and the host part of + ``address`` for SNI. Passing a mapping also enables TLS, with optional + custom ``server_name`` and ``ca_cert``. + """ + + +class SandboxEgressProxyTLSInfo(TypedDict): + """ + TLS status for the egress proxy connection. The CA certificate is never + returned by the API. + """ + + server_name: NotRequired[str] + """See :attr:`SandboxEgressProxyTLSOpts.server_name`.""" + class SandboxEgressProxyInfo(TypedDict): """ Egress proxy as returned by the sandbox info endpoint. Mirrors - :class:`SandboxEgressProxyOpts` without ``password`` — the API never - returns it. + :class:`SandboxEgressProxyOpts` without ``password`` or ``ca_cert`` — the + API never returns them. """ address: str @@ -327,6 +374,9 @@ class SandboxEgressProxyInfo(TypedDict): username: NotRequired[str] """See :attr:`SandboxEgressProxyOpts.username`.""" + tls: NotRequired[SandboxEgressProxyTLSInfo] + """Set when TLS to the proxy is on.""" + class SandboxNetworkOpts(TypedDict): """ @@ -469,6 +519,9 @@ class SandboxNetworkUpdate(TypedDict, total=False): an update that leaves this out stops tunneling and sends the sandbox's traffic out directly — even when the update was only meant to change the allow and deny lists. Repeat it in every update that should keep tunneling. + + The same replace semantics apply to ``tls``: an update that repeats + ``address`` but omits ``tls`` keeps tunneling without TLS. """ allow_internet_access: bool @@ -810,6 +863,28 @@ def _build_egress_proxy( body.username = egress_proxy["username"] if egress_proxy.get("password") is not None: body.password = egress_proxy["password"] + tls = egress_proxy.get("tls") + if tls is not None: + if isinstance(tls, bool): + if tls: + body.tls = ClientSandboxEgressProxyTLSConfig(enabled=True) + elif isinstance(tls, Mapping): + if "enabled" in tls: + raise InvalidArgumentException( + "network egress_proxy tls takes no 'enabled'; pass " + "tls=True or tls=False instead." + ) + + client_tls = ClientSandboxEgressProxyTLSConfig(enabled=True) + if tls.get("server_name") is not None: + client_tls.server_name = tls["server_name"] + if tls.get("ca_cert") is not None: + client_tls.ca_cert = tls["ca_cert"] + body.tls = client_tls + else: + raise InvalidArgumentException( + "network egress_proxy tls must be a bool or a dict." + ) return body @@ -1049,6 +1124,14 @@ def _from_client_egress_proxy( result: SandboxEgressProxyInfo = {"address": egress_proxy.address} if not isinstance(egress_proxy.username, Unset): result["username"] = egress_proxy.username + if ( + isinstance(egress_proxy.tls, ClientSandboxEgressProxyTLSConfig) + and egress_proxy.tls.enabled is True + ): + tls: SandboxEgressProxyTLSInfo = {} + if not isinstance(egress_proxy.tls.server_name, Unset): + tls["server_name"] = egress_proxy.tls.server_name + result["tls"] = tls return result diff --git a/packages/python-sdk/tests/shared/sandbox/test_egress_proxy.py b/packages/python-sdk/tests/shared/sandbox/test_egress_proxy.py index 75f3f1bc3c..58351ad918 100644 --- a/packages/python-sdk/tests/shared/sandbox/test_egress_proxy.py +++ b/packages/python-sdk/tests/shared/sandbox/test_egress_proxy.py @@ -2,11 +2,14 @@ import pytest -from e2b import SandboxNetworkOpts, SandboxNetworkUpdate +from e2b import SandboxEgressProxyTLSOpts, SandboxNetworkOpts, SandboxNetworkUpdate from e2b.api.client.models import SandboxNetworkConfig from e2b.api.client.models import ( SandboxEgressProxyConfigType0 as ClientSandboxEgressProxyConfig, ) +from e2b.api.client.models import ( + SandboxEgressProxyTLSConfigType0 as ClientSandboxEgressProxyTLSConfig, +) from e2b.api.client.types import UNSET from e2b.exceptions import InvalidArgumentException from e2b.sandbox.sandbox_api import ( @@ -40,6 +43,88 @@ def test_create_sends_an_address_only_egress_proxy(): assert body["egress_proxy"].to_dict() == {"address": "proxy.example.com:1080"} +def test_create_sends_egress_proxy_tls_options(): + tls: SandboxEgressProxyTLSOpts = { + "server_name": "proxy.example.com", + "ca_cert": "-----BEGIN CERTIFICATE-----", + } + body = build_network_config( + {"egress_proxy": {"address": "proxy.example.com:1080", "tls": tls}} + ) + + assert body is not None + assert body["egress_proxy"].to_dict() == { + "address": "proxy.example.com:1080", + "tls": { + "enabled": True, + "serverName": "proxy.example.com", + "caCert": "-----BEGIN CERTIFICATE-----", + }, + } + + +@pytest.mark.parametrize( + ("tls", "wire_tls"), + [ + pytest.param(True, {"enabled": True}, id="true"), + pytest.param({}, {"enabled": True}, id="empty-options"), + ], +) +def test_create_sends_tls_settings(tls, wire_tls): + body = build_network_config( + {"egress_proxy": {"address": "proxy.example.com:1080", "tls": tls}} + ) + + assert body is not None + assert body["egress_proxy"].to_dict() == { + "address": "proxy.example.com:1080", + "tls": wire_tls, + } + + +def test_create_omits_none_tls_options_and_strips_unknown_keys(): + network = cast( + Any, + { + "egress_proxy": { + "address": "proxy.example.com:1080", + "tls": { + "server_name": None, + "ca_cert": None, + "protocol": "tls", + }, + }, + }, + ) + + body = build_network_config(network) + + assert body is not None + assert body["egress_proxy"].to_dict() == { + "address": "proxy.example.com:1080", + "tls": {"enabled": True}, + } + + +@pytest.mark.parametrize( + "tls", + [ + pytest.param(False, id="false"), + pytest.param(None, id="none"), + ], +) +def test_create_omits_false_or_none_tls_options(tls): + body = build_network_config( + cast( + Any, + {"egress_proxy": {"address": "proxy.example.com:1080", "tls": tls}}, + ) + ) + + assert body is not None + assert "tls" not in body["egress_proxy"].to_dict() + + def test_create_combines_the_egress_proxy_with_allow_and_deny_lists(): network: SandboxNetworkOpts = { "allow_out": ["api.example.com"], @@ -85,6 +170,56 @@ def test_create_rejects_a_malformed_egress_proxy(egress_proxy): build_network_config(cast(Any, {"egress_proxy": egress_proxy})) +@pytest.mark.parametrize( + "tls", + [ + pytest.param("tls", id="string"), + pytest.param([], id="array"), + ], +) +def test_create_rejects_malformed_egress_proxy_tls(tls): + with pytest.raises( + InvalidArgumentException, + match="network egress_proxy tls must be a bool or a dict\\.", + ): + build_network_config( + cast( + Any, + { + "egress_proxy": { + "address": "proxy.example.com:1080", + "tls": tls, + } + }, + ) + ) + + +@pytest.mark.parametrize( + "tls", + [ + pytest.param({"enabled": False}, id="enabled-false"), + pytest.param({"enabled": True}, id="enabled-true"), + ], +) +def test_create_rejects_egress_proxy_tls_enabled_key(tls): + with pytest.raises( + InvalidArgumentException, + match="network egress_proxy tls takes no 'enabled'; pass tls=True or tls=False instead\\.", + ): + build_network_config( + cast( + Any, + { + "egress_proxy": { + "address": "proxy.example.com:1080", + "tls": tls, + } + }, + ) + ) + + def test_create_omits_none_credentials(): # Reading a credential out of an unset environment variable is how a caller # lands here, and it means the proxy takes no credentials — the API only @@ -137,6 +272,93 @@ def test_update_sets_the_egress_proxy(): } +def test_update_sends_egress_proxy_tls_options(): + network: SandboxNetworkUpdate = { + "egress_proxy": { + "address": "proxy.example.com:1080", + "tls": { + "server_name": "proxy.example.com", + "ca_cert": "-----BEGIN CERTIFICATE-----", + }, + }, + } + + assert build_network_update_body(network).to_dict() == { + "egressProxy": { + "address": "proxy.example.com:1080", + "tls": { + "enabled": True, + "serverName": "proxy.example.com", + "caCert": "-----BEGIN CERTIFICATE-----", + }, + }, + } + + +def test_update_sends_true_tls_as_enabled(): + network: SandboxNetworkUpdate = { + "egress_proxy": { + "address": "proxy.example.com:1080", + "tls": True, + }, + } + + assert build_network_update_body(network).to_dict() == { + "egressProxy": { + "address": "proxy.example.com:1080", + "tls": {"enabled": True}, + }, + } + + +@pytest.mark.parametrize( + "tls", + [ + pytest.param({"enabled": False}, id="enabled-false"), + pytest.param({"enabled": True}, id="enabled-true"), + ], +) +def test_update_rejects_egress_proxy_tls_enabled_key(tls): + with pytest.raises( + InvalidArgumentException, + match="network egress_proxy tls takes no 'enabled'; pass tls=True or tls=False instead\\.", + ): + build_network_update_body( + cast( + Any, + { + "egress_proxy": { + "address": "proxy.example.com:1080", + "tls": tls, + } + }, + ) + ) + + +@pytest.mark.parametrize( + "tls", + [ + pytest.param(False, id="false"), + pytest.param(None, id="none"), + ], +) +def test_update_omits_false_or_none_tls_options(tls): + network = cast( + Any, + { + "egress_proxy": { + "address": "proxy.example.com:1080", + "tls": tls, + }, + }, + ) + + assert build_network_update_body(network).to_dict() == { + "egressProxy": {"address": "proxy.example.com:1080"}, + } + + def test_update_without_the_egress_proxy_clears_it(): # The update replaces the whole configuration instead of merging into it, so # omitting the proxy stops tunneling rather than leaving it in place. @@ -165,6 +387,44 @@ def test_get_info_reports_the_active_egress_proxy_without_the_password(): } +def test_get_info_reports_egress_proxy_tls_without_the_ca_certificate(): + info = from_client_network_config( + SandboxNetworkConfig( + egress_proxy=ClientSandboxEgressProxyConfig( + address="proxy.example.com:1080", + tls=ClientSandboxEgressProxyTLSConfig( + enabled=True, + server_name="proxy.example.com", + ca_cert="-----BEGIN CERTIFICATE-----", + ), + ), + ) + ) + + assert info is not None + assert info["egress_proxy"] == { + "address": "proxy.example.com:1080", + "tls": {"server_name": "proxy.example.com"}, + } + + +def test_get_info_omits_egress_proxy_tls_when_disabled(): + info = from_client_network_config( + SandboxNetworkConfig( + egress_proxy=ClientSandboxEgressProxyConfig( + address="proxy.example.com:1080", + tls=ClientSandboxEgressProxyTLSConfig( + enabled=False, + server_name="proxy.example.com", + ), + ), + ) + ) + + assert info is not None + assert info["egress_proxy"] == {"address": "proxy.example.com:1080"} + + @pytest.mark.parametrize( "egress_proxy", [ diff --git a/spec/envd/envd.yaml b/spec/envd/envd.yaml index 9e1a304ef1..670f8ac3f0 100644 --- a/spec/envd/envd.yaml +++ b/spec/envd/envd.yaml @@ -77,6 +77,13 @@ paths: caBundle: type: string description: PEM-encoded CA certificates to install into the system trust store (may contain multiple concatenated PEM blocks) + cpuCount: + type: integer + minimum: 1 + description: >- + Number of guest CPUs that should be online. envd attempts to bring + the set of requested CPUs online asynchronously; the outcome is + reported on the X-Envd-Cpus header and on /metrics. responses: "204": @@ -521,7 +528,22 @@ components: description: Unix timestamp in UTC for current sandbox time cpu_count: type: integer - description: Number of CPU cores + description: Number of CPU cores online + cpu_possible: + type: integer + description: Number of CPUs the guest can bring online + cpu_target: + type: integer + description: Online count last requested, 0 when none was + cpu_target_attempts: + type: integer + description: Attempts at reaching cpu_target since it was set + cpu_write_pending_ms: + type: integer + format: int64 + description: >- + How long a CPU online or offline write has been running in the guest kernel, + 0 when none is. A value that keeps growing means CPU hotplug is stuck. cpu_used_pct: type: number format: float diff --git a/spec/openapi.yml b/spec/openapi.yml index f293777631..e10df04307 100644 --- a/spec/openapi.yml +++ b/spec/openapi.yml @@ -592,6 +592,43 @@ components: maxLength: 255 description: >- Optional SOCKS5 password (RFC 1929), max 255 bytes. + tls: + $ref: "#/components/schemas/SandboxEgressProxyTLSConfig" + + SandboxEgressProxyTLSConfig: + type: object + nullable: true + description: >- + TLS for the connection to the SOCKS5 proxy. The SOCKS5 negotiation and + the tunneled traffic both run inside the TLS session, so the proxy + credentials are not sent in the clear. This secures only the hop to the + proxy; what the proxy does onward is its own concern. A half-close from + the sandbox reaches the proxy as a TLS close_notify, not a TCP FIN, and + a proxy that treats close_notify as a full close cuts the reply short. + required: + - enabled + properties: + enabled: + type: boolean + description: >- + Connect to the proxy over TLS. When false, no other field in this + object may be set. + serverName: + type: string + maxLength: 253 + description: >- + Name to verify the proxy certificate against, and to send as SNI. + Defaults to the host part of address. Set this only when the + certificate does not match the address the proxy is reached at. + caCert: + type: string + maxLength: 8192 + description: >- + One or more PEM-encoded certificates to verify the proxy against, + for a proxy fronted by a private CA. These replace the system trust + store, which is what is used when this is omitted. The system trust + store depends on the host the orchestrator runs on, so set this to + get the same verification everywhere. SandboxAutoResumeEnabled: type: boolean @@ -1506,7 +1543,8 @@ components: type: string filesHash: type: string - description: Hash of the files used in the step + pattern: "^[0-9a-f]{64}$" + description: Hash of the files used in the step (lowercase hex SHA-256) force: default: false type: boolean @@ -3816,7 +3854,8 @@ paths: required: true schema: type: string - description: Hash of the files + pattern: "^[0-9a-f]{64}$" + description: Hash of the files (lowercase hex SHA-256) responses: "201": diff --git a/spec/runtime-ref b/spec/runtime-ref index abf34cf2b2..aa343f64db 100644 --- a/spec/runtime-ref +++ b/spec/runtime-ref @@ -1 +1 @@ -90ad3bdbb1a4ad78161463dc90b66b4f8303897b +9cc9ca757b7cfa011874b6a04b82deff7ca771cf