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

57 changes: 57 additions & 0 deletions packages/api/internal/handlers/sandbox_network_update.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
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 allowedCIDRs []string
if body.AllowOut != nil {
allowedCIDRs = *body.AllowOut
}

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

if apiErr := a.orchestrator.UpdateSandboxNetworkConfig(ctx, team.ID, sandboxID, allowedCIDRs, deniedCIDRs); 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
133 changes: 43 additions & 90 deletions packages/orchestrator/internal/sandbox/network/firewall.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package network
import (
"fmt"
"net/netip"
"slices"

"github.com/google/nftables"
"github.com/google/nftables/binaryutil"
Expand Down Expand Up @@ -94,7 +95,7 @@ func NewFirewall(tapIf string, orchestratorInternalIP string) (*Firewall, error)
}

// Populate the sets with initial data
err = fw.Reset()
err = fw.ReplaceUserRules(nil, nil)
if err != nil {
return nil, fmt.Errorf("error while configuring initial data: %w", err)
}
Expand Down Expand Up @@ -240,120 +241,72 @@ func (fw *Firewall) installRules() error {
return nil
}

// AddDeniedCIDR adds a single CIDR to the deny set at runtime.
func (fw *Firewall) AddDeniedCIDR(cidr string) error {
err := addCIDRToSet(fw.conn, fw.userDenySet, cidr)
if err != nil {
return fmt.Errorf("add denied CIDR to set: %w", err)
// ReplaceUserRules atomically replaces all firewall sets in a single flush.
// This avoids any window where rules are partially applied.
func (fw *Firewall) ReplaceUserRules(allowedCIDRs, deniedCIDRs []string) error {
// 1. Reset predefined deny set to default blocked ranges (buffered, no flush).
if err := fw.predefinedDenySet.ClearAndAddElements(fw.conn, sandbox_network.DeniedSandboxSetData); err != nil {
return fmt.Errorf("reset predefined deny set: %w", err)
}

err = fw.conn.Flush()
// 2. Reset predefined allow set to allowedRanges (buffered, no flush).
allowedSetData, err := set.AddressStringsToSetData(fw.allowedRanges)
if err != nil {
return fmt.Errorf("flush add denied changes: %w", err)
}

return nil
}

// AddAllowedCIDR adds a single CIDR to the allow set at runtime.
func (fw *Firewall) AddAllowedCIDR(cidr string) error {
err := addCIDRToSet(fw.conn, fw.userAllowSet, cidr)
if err != nil {
return fmt.Errorf("add allowed CIDR to set: %w", err)
return fmt.Errorf("parse initial allowed CIDRs: %w", err)
}

err = fw.conn.Flush()
if err != nil {
return fmt.Errorf("flush add allowed changes: %w", err)
if err := fw.predefinedAllowSet.ClearAndAddElements(fw.conn, allowedSetData); err != nil {
return fmt.Errorf("reset predefined allow set: %w", err)
}

return nil
}

func (fw *Firewall) Reset() error {
if err := fw.ResetDeniedSets(); err != nil {
return fmt.Errorf("clear denied set: %w", err)
}
if err := fw.ResetAllowedSets(); err != nil {
return fmt.Errorf("clear allow set: %w", err)
// 3. Replace user deny set with new denied CIDRs (buffered, no flush).
if err := clearAndReplaceCIDRs(fw.conn, fw.userDenySet, deniedCIDRs); err != nil {
return fmt.Errorf("replace user deny set: %w", err)
}

return nil
}

// ResetDeniedSets resets the deny set back to original ranges.
func (fw *Firewall) ResetDeniedSets() error {
// Always deny the default ranges
if err := fw.predefinedDenySet.ClearAndAddElements(fw.conn, sandbox_network.DeniedSandboxSetData); err != nil {
return err
// 4. Replace user allow set with new allowed CIDRs (buffered, no flush).
if err := clearAndReplaceCIDRs(fw.conn, fw.userAllowSet, allowedCIDRs); err != nil {
return fmt.Errorf("replace user allow set: %w", err)
}

// User defined denied ranges
if err := fw.userDenySet.ClearAndAddElements(fw.conn, nil); err != nil {
return err
}

return fw.conn.Flush()
}

// ResetAllowedSets resets allow set back to original ranges.
func (fw *Firewall) ResetAllowedSets() error {
// Always allowed ranges
initData, err := set.AddressStringsToSetData(fw.allowedRanges)
if err != nil {
return fmt.Errorf("parse initial allowed CIDRs: %w", err)
}
if err := fw.predefinedAllowSet.ClearAndAddElements(fw.conn, initData); err != nil {
return err
}

// User defined allowed ranges
if err := fw.userAllowSet.ClearAndAddElements(fw.conn, nil); err != nil {
return err
// 5. Single atomic flush.
if err := fw.conn.Flush(); err != nil {
return fmt.Errorf("flush atomic rule replacement: %w", err)
}

return fw.conn.Flush()
return nil
}

func addCIDRToSet(conn *nftables.Conn, ipset set.Set, cidr string) error {
current, err := ipset.Elements(conn)
if err != nil {
return err
}
// clearAndReplaceCIDRs flushes a set and repopulates it with the given CIDRs.
// Handles the special 0.0.0.0/0 case which the firewall_toolkit validation
// rejects (0.0.0.0 is "unspecified") by directly creating nftables elements.
func clearAndReplaceCIDRs(conn *nftables.Conn, s set.Set, cidrs []string) error {
conn.FlushSet(s.Set())

// The checked range is 0.0.0.0 to 255.255.255.254, because when 255.255.255.255 is added, it's then requested as 255.255.255.254.
if len(current) == 1 && current[0].AddressRangeStart == netip.MustParseAddr("0.0.0.0") && current[0].AddressRangeEnd == netip.MustParseAddr("255.255.255.254") {
// Because 0.0.0.0/0 is not valid IP per GoLang, we can't add new addresses to the set.
if len(cidrs) == 0 {
return nil
}

// 0.0.0.0/0 is not valid IP per GoLang, so we handle it as a special case
if cidr == sandbox_network.AllInternetTrafficCIDR {
conn.FlushSet(ipset.Set())

toAppend := []nftables.SetElement{
{
Key: netip.MustParseAddr("0.0.0.0").AsSlice(),
},
{
Key: netip.MustParseAddr("255.255.255.255").AsSlice(),
IntervalEnd: true,
},
// 0.0.0.0/0 must be handled specially: the firewall_toolkit's
// ValidateAddress rejects 0.0.0.0 as "unspecified", so we bypass
// the toolkit and create raw nftables interval elements directly.
if slices.Contains(cidrs, sandbox_network.AllInternetTrafficCIDR) {
elems := []nftables.SetElement{
{Key: netip.MustParseAddr("0.0.0.0").AsSlice()},
{Key: netip.MustParseAddr("255.255.255.255").AsSlice(), IntervalEnd: true},
}

if err := conn.SetAddElements(ipset.Set(), toAppend); err != nil {
return fmt.Errorf("add elements to denied set: %w", err)
if err := conn.SetAddElements(s.Set(), elems); err != nil {
return fmt.Errorf("add all-traffic elements: %w", err)
}

return nil
}

data, err := set.AddressStringsToSetData([]string{cidr})
data, err := set.AddressStringsToSetData(cidrs)
if err != nil {
return err
}

merged := append(current, data...)

return ipset.ClearAndAddElements(conn, merged)
// We already flushed the set above, so just add the new elements.
// Using ClearAndAddElements would flush again which is fine (FlushSet is idempotent
// when buffered), but this is more direct.
return s.ClearAndAddElements(conn, data)
}
Loading