Skip to content
Open
Show file tree
Hide file tree
Changes from 36 commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
2fbecf5
feat(api): add PUT /sandboxes/{sandboxID}/network endpoint (stage 1)
levb Mar 8, 2026
af2b869
fix(test): use valid-format sandbox ID in TestUpdateNetworkConfig_Not…
levb Mar 8, 2026
d541327
feat(orchestrator): wire dynamic network egress updates end-to-end
levb Mar 8, 2026
2db7190
refactor(api): simplify UpdateSandboxNetworkConfig return and remove …
levb Mar 8, 2026
4511929
feat(api): support domain filtering in network update path
levb Mar 8, 2026
d4f93b8
test: add pause/resume persistence test for dynamic network updates
levb Mar 8, 2026
027a24a
fix: address revive lint for redundant if-return in update_network
levb Mar 8, 2026
f8c876f
test: add domain allow/remove tests for dynamic network updates
levb Mar 8, 2026
d5c8fbc
refactor: merge 3 RWMutexes into 1, guarantee GetNetwork() non-nil, u…
levb Mar 8, 2026
286c6f1
fix: validate CIDRs before firewall reset, add 409 to OpenAPI spec, u…
levb Mar 8, 2026
ef4708b
chore: auto-commit generated changes
github-actions[bot] Mar 8, 2026
cc5e43d
fix(test): initialize networkEgress in TestIsEgressAllowed
levb Mar 8, 2026
c452eae
Merge branch 'lev-allow-deny-dynamic' of github.com:e2b-dev/infra int…
levb Mar 8, 2026
8bd0c92
fix(api): persist store update only after gRPC node update succeeds
levb Mar 8, 2026
942d1e1
refactor: migrate all callers to atomic ReplaceUserRules, remove one-…
levb Mar 8, 2026
66dcfbb
chore: auto-commit generated changes
github-actions[bot] Mar 8, 2026
b986f2c
fix: bypass firewall_toolkit validation for 0.0.0.0/0 CIDR
levb Mar 8, 2026
1a95c13
lint
levb Mar 8, 2026
79cc831
fix(api): validate denyOut and domain rules in network update endpoint
levb Mar 9, 2026
8bf22fa
test: consolidate network update integration tests into comprehensive…
levb Mar 9, 2026
da52196
PR feedback: extract shared egress validation and use ReportErrorByCode
levb Mar 9, 2026
3e5923b
PR feedback: embed SandboxNetworkEgressConfig in update network request
levb Mar 9, 2026
73d370a
PR feedback: move state check into updateFunc to match KeepAliveFor p…
levb Mar 9, 2026
9417d82
PR feedback: always set firewallCustomRules after UpdateInternet
levb Mar 9, 2026
b853ee4
lint: simplify redundant if-return in updateSandboxNetworkOnNode call
levb Mar 9, 2026
dcbc89d
PR feedback: merge UpdateNetwork into unified Update RPC with transac…
levb Mar 9, 2026
ed87f50
PR feedback: remove unnecessary networkEgress init from ResumeSandbox
levb Mar 10, 2026
0b2193b
Merge branch 'main' of github.com:e2b-dev/infra into lev-allow-deny-d…
levb Mar 10, 2026
9524334
fix: restore networkEgress init in ResumeSandbox
levb Mar 10, 2026
31175f4
Merge branch 'main' of github.com:e2b-dev/infra into lev-allow-deny-d…
levb Mar 10, 2026
2c46352
PR feedback: unify egress config building and add domain validation
levb Mar 10, 2026
50fdf2f
PR feedback: extract ApplyAllOrRollback utility for transactional upd…
levb Mar 10, 2026
f8c2100
PR feedback: move network config into Config with own RWMutex
levb Mar 10, 2026
e1cc432
PR feedback: handle wildcard domains in IDNA validation
levb Mar 10, 2026
fe7a6db
PR feedback: rename ApplyAllOrRollback to ApplyAllOrNone, fix test
levb Mar 10, 2026
a457ea6
Merge branch 'main' of github.com:e2b-dev/infra into lev-allow-deny-d…
levb Mar 11, 2026
a5d435c
PR feedback: removed questionable domain "validation"
levb Mar 12, 2026
aa11b20
PR feedback: restored accidentally removed comment
levb Mar 12, 2026
bc61dae
PR feedback: clarify nftables buffering semantics in clearAndReplaceC…
levb Mar 12, 2026
15ee24a
PR feedback: add NewConfig constructor to guarantee non-nil network c…
levb Mar 12, 2026
6f21b57
Merge branch 'main' of github.com:e2b-dev/infra into lev-allow-deny-d…
levb Mar 12, 2026
19f2de9
Fix and simplify sandboxes_update_test.go after merge with main
levb Mar 12, 2026
d8be629
Fix nlreturn lint: add blank line before return in clearAndReplaceCIDRs
levb Mar 12, 2026
e1eee7f
Fix data race on network egress/ingress config access
levb Mar 12, 2026
5d7abe5
Merge branch 'main' of github.com:e2b-dev/infra into lev-allow-deny-d…
levb Mar 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
528 changes: 374 additions & 154 deletions packages/api/internal/api/api.gen.go

Large diffs are not rendered by default.

34 changes: 29 additions & 5 deletions packages/api/internal/handlers/sandbox_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,16 @@ func validateNetworkConfig(network *api.SandboxNetworkConfig) *api.APIError {
}

denyOut := sharedUtils.DerefOrDefault(network.DenyOut, nil)
allowOut := sharedUtils.DerefOrDefault(network.AllowOut, nil)

return validateEgressRules(allowOut, denyOut)
}

// validateEgressRules validates egress allow/deny rules:
// - denyOut entries must be valid IPs or CIDRs (not domains)
// - allowOut entries must be valid IPs, CIDRs, or domain names
// - when allowOut contains domains, denyOut must include 0.0.0.0/0
func validateEgressRules(allowOut, denyOut []string) *api.APIError {
for _, cidr := range denyOut {
if !sandbox_network.IsIPOrCIDR(cidr) {
return &api.APIError{
Expand All @@ -512,16 +522,30 @@ func validateNetworkConfig(network *api.SandboxNetworkConfig) *api.APIError {
}
}

// Validate that allow out rules have corresponding deny out rules
allowOut := sharedUtils.DerefOrDefault(network.AllowOut, nil)
if len(allowOut) > 0 {
_, allowedDomains := sandbox_network.ParseAddressesAndDomains(allowOut)

// Check if DenyOut contains block-all CIDR
for _, domain := range allowedDomains {
// Strip wildcard prefix for IDNA validation (*.example.com → example.com).
// The "*" label is not a valid IDNA label, but we support it as a wildcard.
validateDomain := domain
if strings.HasPrefix(domain, "*.") {
validateDomain = domain[2:]
}

if validateDomain != "*" {
if _, err := idna.Lookup.ToASCII(validateDomain); err != nil {
return &api.APIError{
Code: http.StatusBadRequest,
Err: fmt.Errorf("invalid allowed domain %q: %w", domain, err),
ClientMsg: fmt.Sprintf("invalid allowed domain: %s", domain),
}
}
}
}

hasBlockAll := slices.Contains(denyOut, sandbox_network.AllInternetTrafficCIDR)

// When specifying domains, require block-all CIDR in DenyOut
// Without this, domain filtering is meaningless (traffic is allowed by default)
if len(allowedDomains) > 0 && !hasBlockAll {
return &api.APIError{
Code: http.StatusBadRequest,
Expand Down
63 changes: 63 additions & 0 deletions packages/api/internal/handlers/sandbox_network_update.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package handlers

import (
"fmt"
"net/http"

"github.com/gin-gonic/gin"

"github.com/e2b-dev/infra/packages/api/internal/api"
"github.com/e2b-dev/infra/packages/api/internal/utils"
"github.com/e2b-dev/infra/packages/auth/pkg/auth"
"github.com/e2b-dev/infra/packages/shared/pkg/telemetry"
)

func (a *APIStore) PutSandboxesSandboxIDNetwork(
c *gin.Context,
sandboxID string,
) {
ctx := c.Request.Context()

var err error
sandboxID, err = utils.ShortID(sandboxID)
if err != nil {
a.sendAPIStoreError(c, http.StatusBadRequest, "Invalid sandbox ID")

return
}

team := auth.MustGetTeamInfo(c)

body, err := utils.ParseBody[api.PutSandboxesSandboxIDNetworkJSONBody](ctx, c)
if err != nil {
a.sendAPIStoreError(c, http.StatusBadRequest, fmt.Sprintf("Error when parsing request: %s", err))
telemetry.ReportCriticalError(ctx, "error when parsing request", err)

return
}

var allowedEntries []string
if body.AllowOut != nil {
allowedEntries = *body.AllowOut
}

var deniedEntries []string
if body.DenyOut != nil {
deniedEntries = *body.DenyOut
}

if apiErr := validateEgressRules(allowedEntries, deniedEntries); apiErr != nil {
a.sendAPIStoreError(c, apiErr.Code, apiErr.ClientMsg)

return
}

if apiErr := a.orchestrator.UpdateSandboxNetworkConfig(ctx, team.ID, sandboxID, allowedEntries, deniedEntries); apiErr != nil {
telemetry.ReportErrorByCode(ctx, apiErr.Code, "error updating sandbox network config", apiErr.Err)
a.sendAPIStoreError(c, apiErr.Code, apiErr.ClientMsg)

return
}

c.Status(http.StatusNoContent)
}
34 changes: 19 additions & 15 deletions packages/api/internal/orchestrator/create_instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,24 @@ import (
ut "github.com/e2b-dev/infra/packages/shared/pkg/utils"
)

// buildEgressConfig constructs the orchestrator egress configuration from
// allow/deny entry lists. It splits allowed entries into CIDRs and domains,
// and adds the default nameserver when domains are present so the sandbox can
// resolve them.
func buildEgressConfig(allowedEntries, deniedEntries []string) *orchestrator.SandboxNetworkEgressConfig {
allowedAddresses, allowedDomains := sandbox_network.ParseAddressesAndDomains(allowedEntries)

if len(allowedDomains) > 0 {
allowedAddresses = append(allowedAddresses, sandbox_network.DefaultNameserver)
}

return &orchestrator.SandboxNetworkEgressConfig{
AllowedCidrs: sandbox_network.AddressStringsToCIDRs(allowedAddresses),
DeniedCidrs: sandbox_network.AddressStringsToCIDRs(deniedEntries),
AllowedDomains: allowedDomains,
}
}

// buildNetworkConfig constructs the orchestrator network configuration from the input parameters
func buildNetworkConfig(network *types.SandboxNetworkConfig, allowInternetAccess *bool, trafficAccessToken *string) *orchestrator.SandboxNetworkConfig {
orchNetwork := &orchestrator.SandboxNetworkConfig{
Expand All @@ -40,21 +58,8 @@ func buildNetworkConfig(network *types.SandboxNetworkConfig, allowInternetAccess
},
}

// Copy network configuration if provided
if network != nil && network.Egress != nil {
// Split allowed addresses into CIDRs/IPs and domains for the orchestrator
allowedAddresses, allowedDomains := sandbox_network.ParseAddressesAndDomains(network.Egress.AllowedAddresses)

// If allowed domain is provided, add the default nameserver to the allowed addresses
// This is to ensure that the sandbox can resolve the domain name to the IP address
if len(allowedDomains) > 0 {
allowedAddresses = append(allowedAddresses, sandbox_network.DefaultNameserver)
}

orchNetwork.Egress.AllowedCidrs = sandbox_network.AddressStringsToCIDRs(allowedAddresses)
orchNetwork.Egress.AllowedDomains = allowedDomains

orchNetwork.Egress.DeniedCidrs = sandbox_network.AddressStringsToCIDRs(network.Egress.DeniedAddresses)
orchNetwork.Egress = buildEgressConfig(network.Egress.AllowedAddresses, network.Egress.DeniedAddresses)
}

if network != nil && network.Ingress != nil {
Expand All @@ -64,7 +69,6 @@ func buildNetworkConfig(network *types.SandboxNetworkConfig, allowInternetAccess
// Handle the case where internet access is explicitly disabled
// This should be applied after copying the network config to preserve allowed addresses
if allowInternetAccess != nil && !*allowInternetAccess {
// Block all internet access - this overrides any other blocked addresses
orchNetwork.Egress.DeniedCidrs = []string{sandbox_network.AllInternetTrafficCIDR}
}

Expand Down
113 changes: 113 additions & 0 deletions packages/api/internal/orchestrator/update_network.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package orchestrator

import (
"context"
"errors"
"fmt"
"net/http"

"github.com/google/uuid"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"

"github.com/e2b-dev/infra/packages/api/internal/api"
"github.com/e2b-dev/infra/packages/api/internal/sandbox"
"github.com/e2b-dev/infra/packages/api/internal/utils"
"github.com/e2b-dev/infra/packages/db/pkg/types"
orchestratorgrpc "github.com/e2b-dev/infra/packages/shared/pkg/grpc/orchestrator"
"github.com/e2b-dev/infra/packages/shared/pkg/telemetry"
)

func (o *Orchestrator) UpdateSandboxNetworkConfig(
ctx context.Context,
teamID uuid.UUID,
sandboxID string,
allowedEntries []string,
deniedEntries []string,
) *api.APIError {
egress := buildEgressConfig(allowedEntries, deniedEntries)

updateFunc := func(sbx sandbox.Sandbox) (sandbox.Sandbox, error) {
if sbx.State != sandbox.StateRunning {
return sbx, &sandbox.NotRunningError{SandboxID: sandboxID, State: sbx.State}
}

if sbx.Network == nil {
sbx.Network = &types.SandboxNetworkConfig{}
}

sbx.Network.Egress = &types.SandboxNetworkEgressConfig{
AllowedAddresses: allowedEntries,
DeniedAddresses: deniedEntries,
}

return sbx, nil
}

var sbxNotFoundErr *sandbox.NotFoundError
var sbxNotRunningErr *sandbox.NotRunningError

sbx, err := o.sandboxStore.Update(ctx, teamID, sandboxID, updateFunc)
if err != nil {
switch {
case errors.As(err, &sbxNotRunningErr):
return &api.APIError{Code: http.StatusConflict, ClientMsg: utils.SandboxChangingStateMsg(sandboxID, sbxNotRunningErr.State), Err: err}
case errors.As(err, &sbxNotFoundErr):
return &api.APIError{Code: http.StatusNotFound, ClientMsg: utils.SandboxNotFoundMsg(sandboxID), Err: err}
default:
return &api.APIError{Code: http.StatusInternalServerError, ClientMsg: "Error updating sandbox network config", Err: err}
}
}

// Apply the network update on the orchestrator node.
return o.updateSandboxNetworkOnNode(ctx, sbx, egress)
}

func (o *Orchestrator) updateSandboxNetworkOnNode(
ctx context.Context,
sbx sandbox.Sandbox,
egress *orchestratorgrpc.SandboxNetworkEgressConfig,
) *api.APIError {
ctx, span := tracer.Start(ctx, "update-sandbox-network-on-node",
trace.WithAttributes(
attribute.String("instance.id", sbx.SandboxID),
),
)
defer span.End()

node := o.GetNode(sbx.ClusterID, sbx.NodeID)
if node == nil {
return &api.APIError{
Code: http.StatusInternalServerError,
ClientMsg: fmt.Sprintf("Node hosting sandbox '%s' not found", sbx.SandboxID),
Err: fmt.Errorf("node '%s' not found for cluster '%s'", sbx.NodeID, sbx.ClusterID),
}
}

client, ctx := node.GetClient(ctx)
_, err := client.Sandbox.Update(ctx, &orchestratorgrpc.SandboxUpdateRequest{
SandboxId: sbx.SandboxID,
Egress: egress,
})
if err != nil {
grpcErr, ok := status.FromError(err)
if ok && grpcErr.Code() == codes.NotFound {
return &api.APIError{Code: http.StatusNotFound, ClientMsg: utils.SandboxNotFoundMsg(sbx.SandboxID), Err: err}
}

err = utils.UnwrapGRPCError(err)
telemetry.ReportCriticalError(ctx, "failed to update sandbox network on node", err)

return &api.APIError{
Code: http.StatusInternalServerError,
ClientMsg: "Error applying network config to sandbox",
Err: fmt.Errorf("failed to update sandbox network on node: %w", err),
}
}

telemetry.ReportEvent(ctx, "Updated sandbox network on node")

return nil
}
6 changes: 3 additions & 3 deletions packages/orchestrator/benchmark_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -195,13 +195,12 @@ func BenchmarkBaseImageLaunch(b *testing.B) {
})

accessToken := "access-token"
sandboxConfig := sandbox.Config{
sandboxConfig := &sandbox.Config{
BaseTemplateID: templateID,
Vcpu: 2,
RamMB: 512,
TotalDiskSizeMB: 2 * 1024,
HugePages: useHugePages,
Network: sbxNetwork,
Envd: sandbox.EnvdMetadata{
Vars: map[string]string{"HELLO": "WORLD"},
AccessToken: &accessToken,
Expand All @@ -212,6 +211,7 @@ func BenchmarkBaseImageLaunch(b *testing.B) {
FirecrackerVersion: fcVersion,
},
}
sandboxConfig.SetNetwork(sbxNetwork)

runtime := sandbox.RuntimeMetadata{
TemplateID: templateID,
Expand Down Expand Up @@ -348,7 +348,7 @@ type testContainer struct {
testType testCycle
sandboxFactory *sandbox.Factory
tmpl template.Template
sandboxConfig sandbox.Config
sandboxConfig *sandbox.Config
runtime sandbox.RuntimeMetadata
}

Expand Down
6 changes: 3 additions & 3 deletions packages/orchestrator/cmd/resume-build/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ type runner struct {
factory *sandbox.Factory
sandboxes *sandbox.Map
tmpl template.Template
sbxConfig sandbox.Config
sbxConfig *sandbox.Config
buildID string
cache *template.Cache
coldStart bool
Expand Down Expand Up @@ -1091,18 +1091,18 @@ func run(ctx context.Context, buildID string, iterations int, coldStart, noPrefe
noPrefetch: noPrefetch,
config: config.BuilderConfig,
storage: persistence,
sbxConfig: sandbox.Config{
sbxConfig: &sandbox.Config{
BaseTemplateID: buildID,
Vcpu: 1,
RamMB: 512,
Network: &orchestrator.SandboxNetworkConfig{},
Envd: sandbox.EnvdMetadata{Vars: map[string]string{}, AccessToken: &token, Version: "1.0.0"},
FirecrackerConfig: fc.Config{
KernelVersion: meta.Template.KernelVersion,
FirecrackerVersion: meta.Template.FirecrackerVersion,
},
},
}
r.sbxConfig.SetNetwork(&orchestrator.SandboxNetworkConfig{})

if runOpts.enabled() {
return r.cmdMode(ctx, runOpts)
Expand Down
36 changes: 20 additions & 16 deletions packages/orchestrator/cmd/smoketest/smoke_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,22 +107,26 @@ func TestSmokeAllFCVersions(t *testing.T) { //nolint:paralleltest // subtests sh
sbx, err := infra.factory.ResumeSandbox(
ctx,
tmpl,
sandbox.Config{
BaseTemplateID: "smoke-" + fcMajor,
Vcpu: 2,
RamMB: 512,
HugePages: true,
Network: &orchestrator.SandboxNetworkConfig{},
Envd: sandbox.EnvdMetadata{
Vars: map[string]string{},
AccessToken: &token,
Version: "1.0.0",
},
FirecrackerConfig: fc.Config{
KernelVersion: meta.Template.KernelVersion,
FirecrackerVersion: meta.Template.FirecrackerVersion,
},
},
func() *sandbox.Config {
cfg := &sandbox.Config{
BaseTemplateID: "smoke-" + fcMajor,
Vcpu: 2,
RamMB: 512,
HugePages: true,
Envd: sandbox.EnvdMetadata{
Vars: map[string]string{},
AccessToken: &token,
Version: "1.0.0",
},
FirecrackerConfig: fc.Config{
KernelVersion: meta.Template.KernelVersion,
FirecrackerVersion: meta.Template.FirecrackerVersion,
},
}
cfg.SetNetwork(&orchestrator.SandboxNetworkConfig{})

return cfg
}(),
sandbox.RuntimeMetadata{
TemplateID: "smoke-" + fcMajor,
TeamID: "smoke",
Expand Down
Loading
Loading