Skip to content

Commit 1fc3820

Browse files
arkamarjakubno
andauthored
feat(api): SOCKS5 egress proxy on sandbox network config (BYOP) (#2642)
Sandboxes have two related egress problems we cannot solve from our side: operators want a predictable egress identity (their public IP, not ours), and they want sandboxes to reach hosts that are reachable from their own proxy server — corporate networks, VPN endpoints, internal LAN. This PR adds Bring-Your-Own-Proxy: an optional `egressProxy: {address, username, password}` on the sandbox network config tunnels all sandbox TCP egress through a user-supplied SOCKS5 server, with our allow/deny rules still applied before the tunnel. Scope here is the API + orchestrator plumbing: OpenAPI, DB types, proto, the API create/get handlers (password omitted from GET), the `BYOPProxyEnabledFlag` LD gate, and a validator that rejects a SOCKS5 endpoint pointing at our own infra at create time and re-checks on every dial (DNS-rebind guard). For BYOP-enabled sandboxes only, the sandbox-netns kernel firewall is narrowed on demand so TCP destined for the user's internal networks reaches the userspace egress proxy instead of being dropped — without this the user's LAN destinations would never make it past the kernel. Non-BYOP sandboxes keep the existing hard kernel drop. --------- Co-authored-by: Jakub Novák <jakub@e2b.dev>
1 parent c479dd3 commit 1fc3820

23 files changed

Lines changed: 1160 additions & 392 deletions

File tree

‎packages/api/internal/api/api.gen.go‎

Lines changed: 191 additions & 170 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎packages/api/internal/handlers/sandbox_create.go‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,32 @@ func (a *APIStore) PostSandboxes(c *gin.Context) {
200200
},
201201
}
202202

203+
if ep := n.EgressProxy; ep != nil {
204+
if !a.featureFlags.BoolFlag(ctx, featureflags.BYOPProxyEnabledFlag) {
205+
telemetry.ReportEvent(ctx, "egressProxy rejected by BYOPProxyEnabledFlag")
206+
a.sendAPIStoreError(c, http.StatusForbidden,
207+
"Egress proxy (network.egressProxy) is not enabled for this team.")
208+
209+
return
210+
}
211+
212+
canonical, err := sandbox_network.ValidateEgressProxy(ctx, &sandbox_network.EgressProxyConfig{
213+
Address: ep.Address,
214+
Username: sharedUtils.DerefOrDefault(ep.Username, ""),
215+
Password: sharedUtils.DerefOrDefault(ep.Password, ""),
216+
}, nil)
217+
if err != nil {
218+
telemetry.ReportError(ctx, "invalid egress proxy config", err, telemetry.WithSandboxID(sandboxID))
219+
a.sendAPIStoreError(c, http.StatusBadRequest, fmt.Sprintf("Invalid egress proxy config: %s", err))
220+
221+
return
222+
}
223+
224+
network.Egress.EgressProxyAddress = canonical.Address
225+
network.Egress.EgressProxyUsername = canonical.Username
226+
network.Egress.EgressProxyPassword = canonical.Password
227+
}
228+
203229
// Make sure envd seucre access is enforced when public access is disabled,
204230
// This requirement forces users using newer features to secure sandboxes properly.
205231
if !sharedUtils.DerefOrDefault(network.Ingress.AllowPublicAccess, types.AllowPublicAccessDefault) && envdAccessToken == nil {

‎packages/api/internal/handlers/sandbox_get.go‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,18 @@ func dbNetworkConfigToAPI(network *dbtypes.SandboxNetworkConfig) *api.SandboxNet
7474
}
7575
result.Rules = &apiRules
7676
}
77+
78+
// Password is omitted so credentials never leak via GET.
79+
if egress.EgressProxyAddress != "" {
80+
proxyCfg := &api.SandboxEgressProxyConfig{
81+
Address: egress.EgressProxyAddress,
82+
}
83+
if egress.EgressProxyUsername != "" {
84+
username := egress.EgressProxyUsername
85+
proxyCfg.Username = &username
86+
}
87+
result.EgressProxy = proxyCfg
88+
}
7789
}
7890

7991
return result

‎packages/api/internal/handlers/sandbox_network_update.go‎

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,11 @@ import (
1111
"github.com/e2b-dev/infra/packages/api/internal/sandbox"
1212
"github.com/e2b-dev/infra/packages/api/internal/utils"
1313
"github.com/e2b-dev/infra/packages/auth/pkg/auth"
14+
"github.com/e2b-dev/infra/packages/shared/pkg/featureflags"
1415
"github.com/e2b-dev/infra/packages/shared/pkg/ginutils"
16+
sandbox_network "github.com/e2b-dev/infra/packages/shared/pkg/sandbox-network"
1517
"github.com/e2b-dev/infra/packages/shared/pkg/telemetry"
18+
sharedUtils "github.com/e2b-dev/infra/packages/shared/pkg/utils"
1619
)
1720

1821
func (a *APIStore) PutSandboxesSandboxIDNetwork(c *gin.Context, sandboxID string) {
@@ -52,6 +55,31 @@ func (a *APIStore) PutSandboxesSandboxIDNetwork(c *gin.Context, sandboxID string
5255
return
5356
}
5457

58+
var egressProxy *sandbox_network.EgressProxyConfig
59+
if ep := body.EgressProxy; ep != nil {
60+
if !a.featureFlags.BoolFlag(ctx, featureflags.BYOPProxyEnabledFlag) {
61+
telemetry.ReportEvent(ctx, "egressProxy update rejected by BYOPProxyEnabledFlag")
62+
a.sendAPIStoreError(c, http.StatusForbidden,
63+
"Egress proxy (egressProxy) is not enabled for this team.")
64+
65+
return
66+
}
67+
68+
canonical, err := sandbox_network.ValidateEgressProxy(ctx, &sandbox_network.EgressProxyConfig{
69+
Address: ep.Address,
70+
Username: sharedUtils.DerefOrDefault(ep.Username, ""),
71+
Password: sharedUtils.DerefOrDefault(ep.Password, ""),
72+
}, nil)
73+
if err != nil {
74+
telemetry.ReportError(ctx, "invalid egress proxy config", err)
75+
a.sendAPIStoreError(c, http.StatusBadRequest, fmt.Sprintf("Invalid egress proxy config: %s", err))
76+
77+
return
78+
}
79+
80+
egressProxy = canonical
81+
}
82+
5583
if body.Rules != nil {
5684
sbxInfo, err := a.orchestrator.GetSandbox(ctx, teamID, sandboxID)
5785
if err != nil {
@@ -74,7 +102,7 @@ func (a *APIStore) PutSandboxesSandboxIDNetwork(c *gin.Context, sandboxID string
74102

75103
rules := apiRulesToDBRules(body.Rules)
76104

77-
if apiErr := a.orchestrator.UpdateSandboxNetworkConfig(ctx, teamID, sandboxID, allowedEntries, deniedEntries, rules, body.AllowInternetAccess); apiErr != nil {
105+
if apiErr := a.orchestrator.UpdateSandboxNetworkConfig(ctx, teamID, sandboxID, allowedEntries, deniedEntries, rules, body.AllowInternetAccess, egressProxy); apiErr != nil {
78106
telemetry.ReportErrorByCode(ctx, apiErr.Code, "error updating sandbox network config", apiErr.Err)
79107
a.sendAPIStoreError(c, apiErr.Code, apiErr.ClientMsg)
80108

‎packages/api/internal/orchestrator/create_instance.go‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,16 @@ func buildEgressConfig(allowedEntries, deniedEntries []string, rules map[string]
8989
}
9090
}
9191

92+
// applyEgressProxy copies BYOP SOCKS5 fields from src to dst. No-op on nil.
93+
func applyEgressProxy(dst *orchestrator.SandboxNetworkEgressConfig, src *types.SandboxNetworkEgressConfig) {
94+
if dst == nil || src == nil {
95+
return
96+
}
97+
dst.EgressProxyAddress = src.EgressProxyAddress
98+
dst.EgressProxyUsername = src.EgressProxyUsername
99+
dst.EgressProxyPassword = src.EgressProxyPassword
100+
}
101+
92102
// buildNetworkConfig constructs the orchestrator network configuration from the input parameters
93103
func buildNetworkConfig(network *types.SandboxNetworkConfig, allowInternetAccess *bool, trafficAccessToken *string) *orchestrator.SandboxNetworkConfig {
94104
orchNetwork := &orchestrator.SandboxNetworkConfig{
@@ -99,7 +109,9 @@ func buildNetworkConfig(network *types.SandboxNetworkConfig, allowInternetAccess
99109
}
100110

101111
if network != nil && network.Egress != nil {
102-
orchNetwork.Egress = buildEgressConfig(network.Egress.AllowedAddresses, network.Egress.DeniedAddresses, network.Egress.Rules)
112+
egress := buildEgressConfig(network.Egress.AllowedAddresses, network.Egress.DeniedAddresses, network.Egress.Rules)
113+
applyEgressProxy(egress, network.Egress)
114+
orchNetwork.Egress = egress
103115
}
104116

105117
if network != nil && network.Ingress != nil {

‎packages/api/internal/orchestrator/update_network.go‎

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"github.com/e2b-dev/infra/packages/api/internal/utils"
1818
"github.com/e2b-dev/infra/packages/db/pkg/types"
1919
orchestratorgrpc "github.com/e2b-dev/infra/packages/shared/pkg/grpc/orchestrator"
20+
sandbox_network "github.com/e2b-dev/infra/packages/shared/pkg/sandbox-network"
2021
"github.com/e2b-dev/infra/packages/shared/pkg/telemetry"
2122
)
2223

@@ -28,14 +29,20 @@ func (o *Orchestrator) UpdateSandboxNetworkConfig(
2829
deniedEntries []string,
2930
rules map[string][]types.SandboxNetworkRule,
3031
allowInternetAccess *bool,
32+
egressProxy *sandbox_network.EgressProxyConfig,
3133
) *api.APIError {
32-
network := &types.SandboxNetworkConfig{
33-
Egress: &types.SandboxNetworkEgressConfig{
34-
AllowedAddresses: allowedEntries,
35-
DeniedAddresses: deniedEntries,
36-
Rules: rules,
37-
},
34+
// PUT is full-replace: omitting egressProxy clears BYOP.
35+
egressConfig := &types.SandboxNetworkEgressConfig{
36+
AllowedAddresses: allowedEntries,
37+
DeniedAddresses: deniedEntries,
38+
Rules: rules,
3839
}
40+
if egressProxy != nil {
41+
egressConfig.EgressProxyAddress = egressProxy.Address
42+
egressConfig.EgressProxyUsername = egressProxy.Username
43+
egressConfig.EgressProxyPassword = egressProxy.Password
44+
}
45+
network := &types.SandboxNetworkConfig{Egress: egressConfig}
3946
orchNetwork := buildNetworkConfig(network, allowInternetAccess, nil)
4047
egress := orchNetwork.GetEgress()
4148

@@ -48,11 +55,7 @@ func (o *Orchestrator) UpdateSandboxNetworkConfig(
4855
sbx.Network = &types.SandboxNetworkConfig{}
4956
}
5057

51-
sbx.Network.Egress = &types.SandboxNetworkEgressConfig{
52-
AllowedAddresses: allowedEntries,
53-
DeniedAddresses: deniedEntries,
54-
Rules: rules,
55-
}
58+
sbx.Network.Egress = egressConfig
5659

5760
if allowInternetAccess != nil {
5861
sbx.AllowInternetAccess = allowInternetAccess

‎packages/db/pkg/types/types.go‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,11 @@ type SandboxNetworkEgressConfig struct {
7575
AllowedAddresses []string `json:"allowedAddresses,omitempty"`
7676
DeniedAddresses []string `json:"deniedAddresses,omitempty"`
7777
Rules map[string][]SandboxNetworkRule `json:"rules,omitempty"`
78+
79+
// SOCKS5 BYOP egress proxy configuration.
80+
EgressProxyAddress string `json:"egressProxyAddress,omitempty"`
81+
EgressProxyUsername string `json:"egressProxyUsername,omitempty"`
82+
EgressProxyPassword string `json:"egressProxyPassword,omitempty"`
7883
}
7984

8085
const AllowPublicAccessDefault = true

‎packages/local-dev/docker-compose.yaml‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,16 @@ services:
7878
ports:
7979
- "6379:6379"
8080

81+
socks5:
82+
image: python:3.12-alpine
83+
command: >-
84+
sh -c "pip install --quiet pproxy &&
85+
exec python -u -m pproxy
86+
-l socks5://0.0.0.0:1080#byopuser:byoppass
87+
-v"
88+
ports:
89+
- "1080:1080"
90+
8191
tempo:
8292
image: grafana/tempo:2.8.2
8393
command: [ "-config.file=/etc/tempo.yaml" ]

‎packages/orchestrator/orchestrator.proto‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,11 @@ message SandboxNetworkEgressConfig {
8989
repeated string denied_cidrs = 2;
9090
repeated string allowed_domains = 3;
9191
map<string, SandboxNetworkDomainRules> rules = 4;
92+
93+
// BYOP SOCKS5 egress proxy.
94+
string egress_proxy_address = 5;
95+
string egress_proxy_username = 6;
96+
string egress_proxy_password = 7;
9297
}
9398

9499
message SandboxNetworkIngressConfig {

‎packages/orchestrator/pkg/sandbox/envd_test.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ type mockEgressProxy struct {
2626
func (m *mockEgressProxy) OnSlotCreate(_ *network.Slot, _ *iptables.IPTables) error { return nil }
2727
func (m *mockEgressProxy) OnSlotDelete(_ *network.Slot, _ *iptables.IPTables) error { return nil }
2828
func (m *mockEgressProxy) CABundle() string { return m.bundle }
29+
func (m *mockEgressProxy) SupportsBYOP() bool { return false }
2930

3031
// newTestSandboxWithBundle builds a minimal Sandbox with CABundle set —
3132
// mirroring what Factory.CreateSandbox does with f.egressProxy.CABundle().

0 commit comments

Comments
 (0)