Skip to content
Open
Show file tree
Hide file tree
Changes from 20 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.

81 changes: 81 additions & 0 deletions packages/api/internal/handlers/sandbox_network_update.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package handlers

import (
"fmt"
"net/http"
"slices"

"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"
sandbox_network "github.com/e2b-dev/infra/packages/shared/pkg/sandbox-network"
"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
}

// Validate denyOut entries are valid IPs/CIDRs (not domains).
for _, entry := range deniedEntries {
if !sandbox_network.IsIPOrCIDR(entry) {
a.sendAPIStoreError(c, http.StatusBadRequest, fmt.Sprintf("invalid denied CIDR %s", entry))

return
}
}

// When specifying domains in allowOut, require deny-all (0.0.0.0/0) in denyOut.
// Without it, domain filtering is meaningless since traffic is allowed by default.
if len(allowedEntries) > 0 {
_, allowedDomains := sandbox_network.ParseAddressesAndDomains(allowedEntries)
hasBlockAll := slices.Contains(deniedEntries, sandbox_network.AllInternetTrafficCIDR)

if len(allowedDomains) > 0 && !hasBlockAll {
a.sendAPIStoreError(c, http.StatusBadRequest, ErrMsgDomainsRequireBlockAll)

return
}
}

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

return
}

c.Status(http.StatusNoContent)
}
138 changes: 138 additions & 0 deletions packages/api/internal/orchestrator/update_network.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
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"
sandbox_network "github.com/e2b-dev/infra/packages/shared/pkg/sandbox-network"
"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 {
// Split allowed entries into CIDRs/IPs and domains, matching the creation
// path in buildNetworkConfig.
allowedAddresses, allowedDomains := sandbox_network.ParseAddressesAndDomains(allowedEntries)

// If allowed domains are provided, add the default nameserver so the sandbox
// can resolve domain names — same as the creation path.
if len(allowedDomains) > 0 {
allowedAddresses = append(allowedAddresses, sandbox_network.DefaultNameserver)
}

allowedCIDRs := sandbox_network.AddressStringsToCIDRs(allowedAddresses)
deniedCIDRs := sandbox_network.AddressStringsToCIDRs(deniedEntries)

// Read the sandbox first to validate state and get routing info,
// without mutating the store yet.
sbx, err := o.sandboxStore.Get(ctx, teamID, sandboxID)
if err != nil {
var sbxNotFoundErr *sandbox.NotFoundError
if errors.As(err, &sbxNotFoundErr) {
return &api.APIError{Code: http.StatusNotFound, ClientMsg: utils.SandboxNotFoundMsg(sandboxID), Err: err}
}

return &api.APIError{Code: http.StatusInternalServerError, ClientMsg: "Error reading sandbox", Err: err}
}

if sbx.State != sandbox.StateRunning {
return &api.APIError{Code: http.StatusConflict, ClientMsg: utils.SandboxChangingStateMsg(sandboxID, sbx.State), Err: fmt.Errorf("sandbox '%s' is not running (state: %s)", sandboxID, sbx.State)}
}

// Apply the network update on the orchestrator node first.
// Only persist to the store after the node update succeeds.
if apiErr := o.updateSandboxNetworkOnNode(ctx, sbx, allowedCIDRs, deniedCIDRs, allowedDomains); apiErr != nil {
return apiErr
}

// Node update succeeded — now persist the new config in the store.
updateFunc := func(sbx sandbox.Sandbox) (sandbox.Sandbox, error) {
if sbx.Network == nil {
sbx.Network = &types.SandboxNetworkConfig{}
}

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

return sbx, nil
}

if _, err := o.sandboxStore.Update(ctx, teamID, sandboxID, updateFunc); err != nil {
telemetry.ReportError(ctx, "network updated on node but failed to persist in store", err)

return &api.APIError{Code: http.StatusInternalServerError, ClientMsg: "Network rules applied but failed to persist config", Err: err}
}

return nil
}

func (o *Orchestrator) updateSandboxNetworkOnNode(
ctx context.Context,
sbx sandbox.Sandbox,
allowedCIDRs []string,
deniedCIDRs []string,
allowedDomains []string,
) *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.UpdateNetwork(ctx, &orchestratorgrpc.SandboxUpdateNetworkRequest{
SandboxId: sbx.SandboxID,
AllowedCidrs: allowedCIDRs,
DeniedCidrs: deniedCIDRs,
AllowedDomains: allowedDomains,
})
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
}
8 changes: 3 additions & 5 deletions packages/orchestrator/internal/proxy/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,8 @@ func NewSandboxProxy(meterProvider metric.MeterProvider, port uint16, sandboxes
return nil, reverseproxy.NewErrSandboxNotFound(sandboxId)
}

var accessToken *string = nil
if net := sbx.Config.Network; net != nil && net.GetIngress() != nil {
accessToken = net.GetIngress().TrafficAccessToken
}
ingress := sbx.GetNetwork().GetIngress()
accessToken := ingress.TrafficAccessToken

isNonEnvdTraffic := int64(port) != consts.DefaultEnvdServerPort

Expand All @@ -92,7 +90,7 @@ func NewSandboxProxy(meterProvider metric.MeterProvider, port uint16, sandboxes

// Handle request host masking only for non-envd traffic.
var maskRequestHost *string = nil
if h := sbx.Config.Network.GetIngress().GetMaskRequestHost(); isNonEnvdTraffic && h != "" {
if h := ingress.GetMaskRequestHost(); isNonEnvdTraffic && h != "" {
h = strings.ReplaceAll(h, pool.MaskRequestHostPortPlaceholder, strconv.FormatUint(port, 10))
maskRequestHost = &h
}
Expand Down
Loading