feat(network): update sandbox egress policy in place, re-evaluating live flows - #1399
feat(network): update sandbox egress policy in place, re-evaluating live flows#1399FakeLearne wants to merge 1 commit into
Conversation
ac738ac to
11f7524
Compare
| .cubemaster | ||
| .update_sandbox_network(&req) | ||
| .await | ||
| .map_err(|e| sandbox_not_found_or_internal(e, sandbox_id))?; |
There was a problem hiding this comment.
High severity — paused/not-running sandbox returns HTTP 500, not the documented 409.
sandbox_not_found_or_internal only maps 130404 (is_not_found()); everything else falls through params_error_or_internal, which maps only 130400/invalid-path to 400 and everything else to internal_error → 500. When the Cubelet's updateNetworkPolicy rejects because the sandbox is paused (ErrorCode_Conflict, 130409), the master forwards 130409 verbatim (updateSandboxNetworkOnNode copies cubeRsp.GetRet().GetRetCode()), and this function turns it into a 500.
That contradicts the handler's own utoipa annotation (409: Sandbox is not running), the README ("update_network returns 409 | Sandbox is paused or already gone"), and the Go SDK docstring ("including 409 when the sandbox is not running"). A deterministic, documented client-state condition also charges against server-side error-rate SLIs.
CubeMasterError::is_conflict() already exists, and the pause/resume endpoints map 130409 → AppError::Conflict via map_update_cubemaster_err/ensure_update_result. Reuse those helpers here instead of sandbox_not_found_or_internal.
|
|
||
| resp.ret | ||
| .into_result() | ||
| .map_err(|e| sandbox_not_found_or_internal(e, sandbox_id))?; |
There was a problem hiding this comment.
Medium severity — unknown sandbox ID returns HTTP 400, not 404.
CubeMaster::UpdateNetwork responds to a missing sandbox with ErrorCode_MasterParamsError (130400), "sandbox not found" (from resolveSandboxHostIP failing) — it never returns 130404 on this path. is_not_found() matches only 130404, so this maps to AppError::BadRequest → 400.
The utoipa annotation documents 404: Sandbox not found, and the Go SDK's UpdateNetwork promises ErrSandboxNotFound (404). fetch_sandbox_detail handles this class of master response with an explicit RET_CODE_NOT_FOUND check; this endpoint has no equivalent. Either have the master return a not-found code (130404) when resolveSandboxHostIP fails here, or map the master's "sandbox not found" params error to AppError::NotFound in this function.
| // domain. It defers to cubevs's own classifier so this cannot disagree with the | ||
| // code that decides where a target is actually installed. | ||
| func needsDNSResolution(cfg *CubeNetworkConfig) bool { | ||
| if slices.ContainsFunc(cfg.AllowOut, cubevs.IsDNSAllowTarget) { |
There was a problem hiding this comment.
Medium severity — this classifier disagrees with the create path and with cubevs's own install logic, contradicting the comment ("this cannot disagree").
cubevs.IsDNSAllowTarget is a thin wrapper over isDNSAllowTarget, which validates DNS labels only and never excludes IP-looking strings first. For "1.2.3.4" it returns true (labels "1","2","3","4" are all valid). But:
- cubevs's actual install logic (
splitAllowOutTargets) checksisIPv4Target→net.ParseIP/isDottedDecimalLikeTargetbeforeisDNSAllowTarget, so"1.2.3.4"is routed to the IP/CIDR map (allow_out_v3), never todns_allow_v2. - The create path (
isAllowOutDomainTargetinCubelet/network/plugin_policy.go) returns false for"1.2.3.4"(isIPv4NetworkTarget→net.ParseIPsucceeds).
Concrete failure: create with allow_internet_access=false, allow_out=["1.2.3.4"], resolver 10.2.0.53 → shouldAppendDNSAllowOut returns false, so no resolver /32 is installed. Then update_network with a policy still naming allow_out=["1.2.3.4"] → needsDNSResolution returns true → withDNSResolverAllowOut folds 10.2.0.53/32 into AllowOut. The update silently widens egress to the DNS resolver (and persists it via DNSAllowOutCIDRs), so a later clone/pause-resume/restart replays a policy the original create never granted.
Fix: exclude IPv4/CIDR/dotted-decimal targets before consulting IsDNSAllowTarget in needsDNSResolution (mirroring splitAllowOutTargets' ordering), or make the exported classifier replicate that ordering.
| network: Option<&SandboxNetworkConfig>, | ||
| ) -> AppResult<()> { | ||
| let cube_network_config = | ||
| build_cube_network_config(allow_internet_access, network)?.unwrap_or_default(); |
There was a problem hiding this comment.
Low severity — an all-empty body opens internet access.
build_cube_network_config(None, None) returns Ok(None), and .unwrap_or_default() yields a CubeNetworkConfig with allow_internet_access = None. On the Cubelet side cubeVSTapRegistration treats a nil AllowInternetAccess as true (the create-time default), so PUT /sandboxes/{id}/network with body {} clears allow_out/deny_out/rules and flips the sandbox to internet-allowed.
The request-model doc says "any field left out clears what the sandbox currently has" — but omitting allowInternetAccess clears it to default-true, which is likely not what a caller sending {} means (typically "clear restrictions", not "permit everything"). This is documented in the README (update_network(network={}, allow_internet_access=False)), so it's consistent behavior, but on an update endpoint where the natural reading of an empty body is "no change", this default is a footgun — at minimum worth a note in the OpenAPI/utoipa docs that omitting allowInternetAccess means "allow", and arguably worth requiring the field explicitly on this endpoint.
Review:
|
…ive flows
Add PUT /sandboxes/{sandboxID}/network so a running sandbox's egress policy can
be replaced without recreating it, and make the update reach traffic that already
exists rather than only future connections.
The route, the status codes (204/400/404/409) and the full-replacement semantics
follow E2B, so an omitted field clears rather than preserves. One divergence is
deliberate: E2B's nftables chain accepts ESTABLISHED,RELATED first, so tightening
a policy there has no effect on open connections and a long-lived connection can
keep using a revoked destination indefinitely. We re-evaluate instead.
## Datapath
Each sandbox carries a policy generation (mvm_meta.policy_version), bumped only
after both planes hold the new policy. Every session caches the generation it was
admitted under, so the next packet on an established flow is re-judged exactly
once per update:
- same verdict: restamped and left alone, costing one policy lookup per flow;
- no longer allowed, or the verdict changed: the session pair is deleted. TCP is
answered with an RST, matching how every other unreachable TCP packet is
handled here, so the guest fails fast instead of stalling on retransmits; UDP
and ICMP have nothing to reset and are dropped.
A verdict *change* retires the flow rather than migrating it. The SNAT and L7
paths disagree about both the reply tuple and which side terminates the TCP
connection, so migration is not expressible; the client reconnects and is judged
as any new flow.
Deleting rather than flagging keeps the retirement self-enforcing, and is what
lets this stay small. A later non-SYN packet finds no session and is reset, so a
revoked flow cannot resume even if a subsequent update re-allows the destination,
while a SYN legitimately opens a fresh connection under the current policy. Both
directions go at once, so reply-path callers need no change either, and neither
does the reaper.
That property holds only once the legacy 80/443 connection drain in do_tcp_nat is
removed, which this does. While that branch is present a deleted session is
indistinguishable from a lost one, and the drain silently undoes the revocation.
It had also lost its own justification independently: written (1b5d2c3) when L7
flows created no session and re-ran the policy lookup per packet, so a DNS-learned
entry expiring broke live connections, it was made redundant by TencentCloud#1347, after which
L7 flows cache their verdict in nat_session and an established flow never consults
the policy maps again. Two of the three causes its comment cites do not hold
either -- both session maps are pinned and no startup path flushes them, and they
are BPF_MAP_TYPE_HASH rather than LRU, so nothing is evicted. The remaining case,
a flow idle past the reaper timeout, contradicts the reaper it depends on:
deleteSessions removes both directions, and the drain then revives the egress
direction alone, without recreating a session, on the sole evidence that the proxy
still holds a socket -- scoped by which flows happen to leave a host socket rather
than by policy, which is why it only ever applied to 80/443. Consequently an L7
keepalive connection left idle past the reaper timeout is now reset rather than
revived, and the application reconnects.
policy_version fits in existing padding, so mvm_meta and nat_session keep their
sizes and the pinned-map layout is unchanged. The kernel verifier accepts
del_session() while the lookup pointer is still held, checked against mvmtap,
nodenic and localgw.
## Control plane
cubevs gains UpdateTAPDevicePolicy, a third apply mode beside applyNetPolicy
(additive) and replaceNetPolicy (flush then refill). Neither fits a live sandbox:
flushing blanks the policy for as long as the refill takes, and swapping the inner
map defeats the HashOfMaps inner cache and pays a synchronize_rcu per update. So
it diffs against the installed maps and writes only what changed. DNS-learned rows
are left untouched, and deny_out's desired set always includes the always-denied
private and link-local ranges.
Ordering is part of the contract: CubeEgress, then CubeVS (which bumps the
generation), then durable state. A failure before the bump leaves flows on their
cached verdict instead of judging them against a half-applied map, and a failure
before the state write replays the previous policy on restart. Nothing is rolled
back; the diff is computed from the live maps, so replaying converges.
For clone, snapshot and pause/resume the new policy is written to all three places
that outlive the update: the network runtime state file (restart recovery), the
Cubelet cubebox store (pause packaging) and Master's sandboxspec (what clone
replays). The sandboxspec write happens synchronously before the response, so
update then snapshot then clone is read-your-writes; a failure there is logged and
does not fail the call, following the same best-effort convention the create path
already applies to that store.
## DNS resolver allowance
The create path folds the sandbox's resolver addresses into allow_out so domain
rules can be resolved at all, but only when the policy names a domain. An update
carries just the caller's targets, so it would silently revoke DNS and black-hole
every domain rule it had just installed. The resolver list is now recorded on
every create -- unconditionally, because a later update may introduce the first
domain -- and folded back in under the same "policy still names a domain"
condition, so dropping every domain also withdraws the implicit DNS access.
Recording is separate from installing: an all-IP policy still gets no resolver
access, since allow_out cannot be narrowed to port 53. The gate therefore asks
whether a target is *installed* as a domain, not whether it looks like a DNS name.
The latter accepts "10.0.0.1" -- digits are valid label characters and nothing
requires a non-numeric top label -- and would fold the resolver into every policy
written with bare IPv4 literals, granting one extra /32 on all ports that the
caller never asked for. cubevs exports that install decision as
IsAllowOutDomainTarget, mirroring splitAllowOutTargets, so the update gate cannot
diverge from the create gate, which has always been IP-first.
An update that neither installs nor clears L7 rules does not contact CubeEgress at
all. That is decided from the old and new rule sets, not from whether the proxy
happens to be configured, so an L3-only sandbox never depends on a component it
does not use.
## Known limitation
Addresses already learned for a domain outlive the rule that produced them until
their DNS TTL expires, so revoking a domain promptly needs a short resolver TTL.
Provenance tracking for learned entries is deferred.
## Testing
Go unit tests cover the map diff (revocation, expanded L7 port sets, deny_out
convergence, DNS port-set replacement), the generation bump and its survival
across metadata rewrites, and the controller's ordering, resolver refold, L3-only
skip and "a failed update keeps the old policy" guarantees. The resolver gate is
exercised over bare, masked, subnet and L7-host target forms, because only the
bare literals distinguish the install decision from a name-shape check. A new BPF
case drives session_policy_revoked through the existing egress-policy harness
across seven verdict/generation combinations.
Ten end-to-end cases were added under a new network_dynamic_update capability.
Two of them are what actually distinguish this feature: a revoked connection must
be torn down, and a still-permitted one must be left alone -- the second is what
stops an implementation that simply kills every session from passing. Their
guest-side holder uses TCP keepalive rather than application bytes, because a peer
that hangs up on unexpected data is indistinguishable from a policy reset, and it
classifies RESET, ALIVE, EOF and DATA separately so only the unambiguous outcomes
are asserted.
Three more cover the compatibility requirement: a clone inherits the updated
policy rather than the create-time one (which also covers read-your-writes, since
clone snapshots immediately after the update returns), a clone of a narrowed
policy is not more permissive, and the policy survives pause/resume. These have to
be end-to-end -- clone and snapshot are not network-aware, they just replay a
stored create request, so nothing under templatecenter changed here and review
alone cannot tell whether the spec write-back landed.
Verified on a live single-node deployment: all ten cases pass on repeated runs,
and examples/network-policy/network_dynamic_update.py walks an IP allow list, a
connection carried across a revoking update (observed as RESET), a domain allow
list, and switching on L7 interception mid-run.
Signed-off-by: yarrischen <yarrischen@tencent.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
11f7524 to
af0fc7c
Compare
| let resp = self | ||
| .cubemaster | ||
| .update_sandbox_network(&req) | ||
| .await |
There was a problem hiding this comment.
409 never reaches clients — a paused/not-active sandbox update surfaces as HTTP 500.
update_network maps errors through sandbox_not_found_or_internal, which special-cases only 404 (is_not_found) and 400 (is_params_error) and turns everything else into internal_error → HTTP 500. A Cubelet ErrorCode_Conflict (130409) — returned here for a paused sandbox or a sandbox with no active network — passes through CubeMaster verbatim (updateSandboxNetworkOnNode copies cubeRsp.Ret.RetCode straight into the response), reaches this .map_err, and is misclassified as a 500.
But the contract promises 409:
- the utoipa annotation declares
(status = 409, description = "Sandbox is not running"); examples/network-policy/README.mdtroubleshooting table: "update_networkreturns 409 | Sandbox is paused or already gone";- the Go SDK docstring: "including 409 when the sandbox is not running".
The sibling update/delete paths avoid exactly this by using ensure_update_result / map_update_cubemaster_err / ensure_create_result, all of which map RET_CODE_CONFLICT (130409) → AppError::Conflict. Suggest routing this call through ensure_update_result (or map_update_cubemaster_err) so paused / not-active sandboxes actually return 409.
| ActiveClose uint8 | ||
| PacketClass uint8 | ||
| L7Scheme uint8 | ||
| PolicyVersion uint32 |
There was a problem hiding this comment.
PR description misdescribes the retirement mechanism — the reaper plays no part.
The PR body claims "the reaper gives blocked sessions a 10s timeout rather than waiting out TCP Established, which is three hours" and describes sessions as "marked blocked". None of that exists in the code: this struct change (adding PolicyVersion) is the only change to reaper.go, there is no blocked marker on nat_session, and there is no 10s timeout anywhere. Revoked sessions are deleted outright at the datapath (del_session in mvmtap.bpf.c) — the flow is reset immediately, and any guest retransmit finds no session and is reset again.
The committed docs (docs/guide/network-policy.md) describe the delete-outright behavior correctly, so this is a stale description in the PR body rather than a code bug — but the "blocked / 10s reaper timeout" framing could mislead reviewers and future maintainers about how retirement actually works, and the "blocked" terminology is now inconsistent with the implementation.
| * No session is re-created, so each packet on the drained flow | ||
| * re-enters this path (per-packet socket lookup — acceptable for | ||
| * draining connections that will eventually close). | ||
| /* No session: the flow was never authorized, or it was retired |
There was a problem hiding this comment.
Behavior change beyond revocation: legitimate L7 connections whose session was lost are now reset instead of drained.
Removing the 80/443 drain block is necessary for revocation to work — with it in place, a revoked L7 flow whose TPROXY socket is still ESTABLISHED would be kept alive by mark re-stamping, defeating the whole feature. But the old block was also the only safeguard for the non-revocation session-loss case its comment documented: "agent restart, map eviction, or expiry". For a connection the current policy still allows, whose session entry was lost (agent/CubeNet restart that drops session state, or reaper expiry of a silent-but-live connection), the old path re-stamped the mark and kept the connection draining; the new path answers the next packet with RST even though the proxy socket is healthy and the flow is permitted.
This consequence isn't called out anywhere in the PR docs (which describe only the revocation semantics). Two options worth considering: document the trade-off explicitly, or narrow the drain to flows the current policy still allows by re-classifying under the new policy before deciding to reset.
| __u8 verdict; | ||
|
|
||
| if (sess->policy_version == mvm_meta->policy_version) | ||
| return false; |
There was a problem hiding this comment.
Re-check is IP-granular, so domain revocation is imprecise when IPs are shared.
session_policy_revoked re-classifies using only the destination IP (classify_egress_flow(ifindex, daddr, dport)). If a revoked domain's IP is still allowed because another rule — or another DNS-learned domain — covers the same IP, an established flow to that IP survives the revocation even though the specific domain was removed. The docs' "known limitation" acknowledges that DNS-learned IPs outlive the rule that created them (until TTL expiry), but the shared-IP case is a distinct consequence: the flow survives indefinitely, not just until TTL, because the IP remains allowed under a different entry. Worth one sentence in the known-limitation note so users understand that domain-level revocation is best-effort at the IP layer.
Add PUT /sandboxes/{sandboxID}/network so a running sandbox's egress policy can be replaced without recreating it, and make the update reach traffic that already exists rather than only future connections.
The route, the status codes (204/400/404/409) and the full-replacement semantics follow E2B, so an omitted field clears rather than preserves. One divergence is deliberate: E2B's nftables chain accepts ESTABLISHED,RELATED first, so tightening a policy there has no effect on open connections and a long-lived connection can keep using a revoked destination indefinitely. We re-evaluate instead.
Datapath
Each sandbox carries a policy generation (mvm_meta.policy_version), bumped only after both planes hold the new policy. Every session caches the generation it was admitted under, so the next packet on an established flow is re-judged exactly once per update:
A verdict change retires the flow rather than migrating it. The SNAT and L7 paths disagree about both the reply tuple and which side terminates the TCP connection, so migration is not expressible; the client reconnects and is judged as any new flow.
Deleting rather than flagging keeps the retirement self-enforcing, and is what lets this stay small. A later non-SYN packet finds no session and is reset, so a revoked flow cannot resume even if a subsequent update re-allows the destination, while a SYN legitimately opens a fresh connection under the current policy. Both directions go at once, so reply-path callers need no change either, and neither does the reaper.
That property holds only once the legacy 80/443 connection drain in do_tcp_nat is removed, which this does. While that branch is present a deleted session is indistinguishable from a lost one, and the drain silently undoes the revocation. It had also lost its own justification independently: written (1b5d2c3) when L7 flows created no session and re-ran the policy lookup per packet, so a DNS-learned entry expiring broke live connections, it was made redundant by #1347, after which L7 flows cache their verdict in nat_session and an established flow never consults the policy maps again. Two of the three causes its comment cites do not hold either -- both session maps are pinned and no startup path flushes them, and they are BPF_MAP_TYPE_HASH rather than LRU, so nothing is evicted. The remaining case, a flow idle past the reaper timeout, contradicts the reaper it depends on: deleteSessions removes both directions, and the drain then revives the egress direction alone, without recreating a session, on the sole evidence that the proxy still holds a socket -- scoped by which flows happen to leave a host socket rather than by policy, which is why it only ever applied to 80/443. Consequently an L7 keepalive connection left idle past the reaper timeout is now reset rather than revived, and the application reconnects.
policy_version fits in existing padding, so mvm_meta and nat_session keep their sizes and the pinned-map layout is unchanged. The kernel verifier accepts del_session() while the lookup pointer is still held, checked against mvmtap, nodenic and localgw.
Control plane
cubevs gains UpdateTAPDevicePolicy, a third apply mode beside applyNetPolicy (additive) and replaceNetPolicy (flush then refill). Neither fits a live sandbox: flushing blanks the policy for as long as the refill takes, and swapping the inner map defeats the HashOfMaps inner cache and pays a synchronize_rcu per update. So it diffs against the installed maps and writes only what changed. DNS-learned rows are left untouched, and deny_out's desired set always includes the always-denied private and link-local ranges.
Ordering is part of the contract: CubeEgress, then CubeVS (which bumps the generation), then durable state. A failure before the bump leaves flows on their cached verdict instead of judging them against a half-applied map, and a failure before the state write replays the previous policy on restart. Nothing is rolled back; the diff is computed from the live maps, so replaying converges.
For clone, snapshot and pause/resume the new policy is written to all three places that outlive the update: the network runtime state file (restart recovery), the Cubelet cubebox store (pause packaging) and Master's sandboxspec (what clone replays). The sandboxspec write happens synchronously before the response, so update then snapshot then clone is read-your-writes; a failure there is logged and does not fail the call, following the same best-effort convention the create path already applies to that store.
DNS resolver allowance
The create path folds the sandbox's resolver addresses into allow_out so domain rules can be resolved at all, but only when the policy names a domain. An update carries just the caller's targets, so it would silently revoke DNS and black-hole every domain rule it had just installed. The resolver list is now recorded on every create -- unconditionally, because a later update may introduce the first domain -- and folded back in under the same "policy still names a domain" condition, so dropping every domain also withdraws the implicit DNS access.
Recording is separate from installing: an all-IP policy still gets no resolver access, since allow_out cannot be narrowed to port 53. The gate therefore asks whether a target is installed as a domain, not whether it looks like a DNS name. The latter accepts "10.0.0.1" -- digits are valid label characters and nothing requires a non-numeric top label -- and would fold the resolver into every policy written with bare IPv4 literals, granting one extra /32 on all ports that the caller never asked for. cubevs exports that install decision as IsAllowOutDomainTarget, mirroring splitAllowOutTargets, so the update gate cannot diverge from the create gate, which has always been IP-first.
An update that neither installs nor clears L7 rules does not contact CubeEgress at all. That is decided from the old and new rule sets, not from whether the proxy happens to be configured, so an L3-only sandbox never depends on a component it does not use.
Known limitation
Addresses already learned for a domain outlive the rule that produced them until their DNS TTL expires, so revoking a domain promptly needs a short resolver TTL. Provenance tracking for learned entries is deferred.
Testing
Go unit tests cover the map diff (revocation, expanded L7 port sets, deny_out convergence, DNS port-set replacement), the generation bump and its survival across metadata rewrites, and the controller's ordering, resolver refold, L3-only skip and "a failed update keeps the old policy" guarantees. The resolver gate is exercised over bare, masked, subnet and L7-host target forms, because only the bare literals distinguish the install decision from a name-shape check. A new BPF case drives session_policy_revoked through the existing egress-policy harness across seven verdict/generation combinations.
Ten end-to-end cases were added under a new network_dynamic_update capability. Two of them are what actually distinguish this feature: a revoked connection must be torn down, and a still-permitted one must be left alone -- the second is what stops an implementation that simply kills every session from passing. Their guest-side holder uses TCP keepalive rather than application bytes, because a peer that hangs up on unexpected data is indistinguishable from a policy reset, and it classifies RESET, ALIVE, EOF and DATA separately so only the unambiguous outcomes are asserted.
Three more cover the compatibility requirement: a clone inherits the updated policy rather than the create-time one (which also covers read-your-writes, since clone snapshots immediately after the update returns), a clone of a narrowed policy is not more permissive, and the policy survives pause/resume. These have to be end-to-end -- clone and snapshot are not network-aware, they just replay a stored create request, so nothing under templatecenter changed here and review alone cannot tell whether the spec write-back landed.
Verified on a live single-node deployment: all ten cases pass on repeated runs, and examples/network-policy/network_dynamic_update.py walks an IP allow list, a connection carried across a revoking update (observed as RESET), a domain allow list, and switching on L7 interception mid-run.