Fail closed unusable userspace SNAT pools#1417
Conversation
There was a problem hiding this comment.
Pull request overview
This PR changes userspace AF_XDP source-NAT pool handling to preserve unusable pool rules in snapshots and fail closed at runtime instead of silently forwarding without SNAT.
Changes:
- Adds
pool_unusablemetadata to Go/Rust source-NAT snapshots. - Splits Rust SNAT lookup into no-match, matched, and unavailable outcomes.
- Updates AF_XDP SNAT decision sites, tests, contract guard, docs, and action log for the fail-closed contract.
Reviewed changes
Copilot reviewed 17 out of 17 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
userspace-dp/tests/snat_contract_doc_guard.rs |
Updates contract guard from fail-open to fail-closed expectations. |
userspace-dp/src/protocol.rs |
Adds unusable-pool fields to Rust snapshot protocol. |
userspace-dp/src/nat.rs |
Adds unavailable SNAT lookup results and fail reasons. |
userspace-dp/src/nat_tests.rs |
Updates/adds SNAT pool failure unit coverage. |
userspace-dp/src/afxdp/poll_descriptor.rs |
Applies fail-closed SNAT handling at AF_XDP decision sites. |
userspace-dp/src/afxdp/mod.rs |
Re-exports/imports new SNAT failure/result types. |
userspace-dp/src/afxdp/forwarding/mod.rs |
Adds forwarding helper returning structured SNAT lookup results. |
README.md |
Updates high-level userspace SNAT status. |
pkg/dataplane/userspace/snapshot.go |
Preserves unusable pool rules in Go snapshots. |
pkg/dataplane/userspace/protocol.go |
Adds unusable-pool fields to Go snapshot protocol. |
pkg/dataplane/userspace/manager_test.go |
Updates snapshot tests for preserved unusable rules. |
docs/userspace-dataplane-gaps.md |
Refreshes capability/gap status for fail-closed pool SNAT. |
docs/userspace-dataplane-architecture.md |
Updates architecture notes for SNAT fail-closed behavior. |
docs/pr/1373-retire-ebpf-dataplane/README.md |
Updates #1377 status summary. |
docs/pr/1373-retire-ebpf-dataplane/plan.md |
Updates retirement-plan dependency summary. |
docs/pr/1373-retire-ebpf-dataplane/plan-1377-snat-pools.md |
Rewrites #1377 runtime boundary from fail-open to fail-closed. |
_Log.md |
Records the PR action and validation commands. |
Comments suppressed due to low confidence (2)
userspace-dp/src/afxdp/poll_descriptor.rs:1237
- This new fail-closed branch is only protected by the doc guard/source-text checks and the lower-level NAT lookup unit tests; I could not find an AF_XDP poll-path test that drives an unavailable SNAT pool through this branch and asserts the packet is recycled without creating/forwarding a session. Because these four branches are the production enforcement point for the fail-closed contract, please add a functional poll-path test (normal and pending-neighbor paths, or a shared helper-level test) that would fail if this
continuewere removed or the error were converted back to a default NAT decision.
Err(failure) => {
record_source_nat_failure(
telemetry,
worker_ctx,
meta,
flow,
from_zone_id,
to_zone_id,
desc.len,
&failure,
);
binding.scratch.scratch_recycle.push(desc.addr);
continue;
userspace-dp/src/afxdp/poll_descriptor.rs:2155
- This pending-neighbor fail-closed branch lacks a functional poll-path test that proves an unavailable SNAT pool prevents missing-neighbor seed session creation. The existing NAT lookup tests do not exercise this session-build retry path, so a regression here could still install an untranslated seed session while the source-text guard passes.
Err(failure) => {
record_source_nat_failure(
telemetry,
worker_ctx,
meta,
flow,
from_zone_id,
to_zone_id,
desc.len,
&failure,
);
binding.scratch.scratch_recycle.push(desc.addr);
continue;
| record_exception( | ||
| worker_ctx.recent_exceptions, | ||
| &worker_ctx.ident, | ||
| failure.exception_reason(), | ||
| packet_length, | ||
| Some(meta), | ||
| Some(&debug), | ||
| worker_ctx.forwarding, | ||
| ); |
Claude r1 review on
|
Round-1 triple-review synthesis on
|
| Reviewer | Verdict |
|---|---|
| Claude | MERGE-READY |
| Codex | MERGE-NEEDS-MINOR (runtime exception drops rule/pool names) |
| Gemini Pro 3 | MERGE-READY (with minor backward-compat note) |
r1 verifies the comprehensive #1377 closure
All three reviewers confirm the structural correctness:
Codex:
"The actual fail-closed runtime gate is in place before session creation/forwarding. All four
poll_descriptor.rssource-NAT sites are gated. The oldmatch_source_nat_for_flow(...).unwrap_or_default()pattern is gone."
Gemini:
"The legacy
unwrap_or_default()fail-open calls were removed. All four source-NAT call sites inpoll_descriptor.rsnow match againstsource_nat_decision_for_flow(). On failure, they immediately recycle the buffer and break out of forwarding logic."
The SourceNatFailureReason enum covers exactly the architecture.md taxonomy: MissingPool, EmptyPool, InvalidPool, InvalidPortRange, WrongAddressFamily, AllocatorExhausted.
Codex MINOR — operator visibility incomplete
"
record_source_nat_failureincrementsexception_packetsand records a recent exception, but it dropsfailure.rule_nameandfailure.pool_name. There is no Rust runtime log line. Go snapshot build logs missing/empty/invalid-range pools with rule/pool, but runtime-only cases like malformed pool address, wrong family, and allocator failure are only visible as generic recent-exception reasons."
The exception_reason string identifies WHAT happened (e.g., source_nat_pool_missing), but not WHICH rule or pool. Operators triaging mixed-rule configs need both.
Gemini deliberate-break note
"Prior behavior allowed 'wrong-family' pools to be silently skipped so that a subsequent, compatible rule could match the packet (the fail-open bug pathway). This PR enforces strict top-down matching: if a rule matches the flow but the pool cannot translate the family, it now explicitly fails closed, shadowing any later rules."
This is correct firewall-matching precedence. Operators who relied on implicit fall-through will see behavior change — proven by the test renaming pool_snat_wrong_family_pool_fails_closed_before_later_rule. Worth a deployment-note callout.
Docs + guard test verified
Codex: "docs/userspace-dataplane-architecture.md removes the stale 'runtime remains fail-open' wording. snat_contract_doc_guard.rs was refreshed to require exactly four source_nat_decision_for_flow call sites and reject stale fail-open wording."
The test guard from prior PRs is now actively pinning the new contract.
In-flight session behavior verified
Gemini: "Active, in-flight sessions are unaffected as they rely entirely on the established fast-path reverse-session state map. The PR makes zero changes to session.rs or forwarding.rs active fast paths."
So config-change-while-sessions-active: existing flows continue, new flows after the change fail-closed.
Recommendation
Strongly consider in this PR (Codex MINOR): extend record_exception to carry rule_name + pool_name from SourceNatFailure. Adding these fields to the existing exception record makes operator triage immediate. Should be a single struct extension.
Defer: Rust runtime log line on each failure (debug log only — recent-exception telemetry is the right operator surface for now).
Document for operators: the deliberate wrong-family fail-closed break (Gemini note) — likely a release-notes / migration-guide entry.
#1377 closure
This PR concludes a multi-round saga. The architecture is correct: Go snapshot marks unusable rules → Rust evaluator decides fail-closed → telemetry records categorized reason. The doc guard pin and architecture.md updates close the documentation contract.
Codex task: task-mpaou1e4-wucm5a. Gemini task: task-mpaoufbi-fz5h20. Not merging — author's decision.
Claude r2 review on
|
773754a to
9276a1a
Compare
Round-2 triple-review synthesis on
|
| Reviewer | Verdict |
|---|---|
| Claude | MERGE-READY |
| Codex | MERGE-NEEDS-MINOR (forwarding test only pins WrongAddressFamily; allocator-exhausted untested) |
| Gemini Pro 3 | MERGE-READY |
r1 MINOR closed — both reviewers verify
Codex:
"Yes. Rust
protocol.rsexportsrule_name/pool_name; Goprotocol.gohas matchingRuleName string \json:'rule_name,omitempty'`andPoolName.record_source_nat_failurenow callsrecord_source_nat_exception, which copiesfailure.rule_nameandfailure.pool_nameinto the exception before pushing it.statusfmt.gorenders non-empty values asrule=andpool=, andstatusfmt_test.gochecksrule=snat-a/pool=pool-a`."
Gemini: quoted verification of all three checks (exception fields + wire + statusfmt).
Codex MINOR — forwarding test scope
"
forwarding/tests.rsadds one forwarding-level case,WrongAddressFamily, assertingrule_name='wrong-family'andpool_name='v6-only'. The other named cases are not pinned there. Lower-levelnat_tests.rsalready covers missing, empty, invalid range, malformed/invalid pool, and wrong-family with rule/pool identity, but I found no allocator-exhausted propagation test."
The cross-layer coverage:
nat_tests.rs: pins identity at the matcher layer for missing/empty/invalid/malformed/wrong-familyforwarding/tests.rs: pins identity at the forwarding-runtime layer ONLY for wrong-family- Allocator-exhausted: untested at either layer
Defensible to ship as-is since the nat_tests coverage proves the identity flows through SourceNatFailure::for_rule. Allocator-exhausted is a real gap worth a follow-up.
Gemini backward-compat verification
"This is additive JSON. Rust has no
deny_unknown_fields, Go uses normalencoding/jsondecoding. Older peers should ignore the new keys and still parseProcessStatus."
Recommendation
MERGE-READY. Strongly consider adding allocator-exhausted forwarding test (Codex MINOR) — easy to add now that the test fixture exists for wrong-family.
Codex task: task-mpapya9a-m8xuea. Gemini task: task-mpapyj0h-77y36a. Not merging — author's decision.
9276a1a to
5ebd76a
Compare
Claude r3 review on
|
| let Some(egress) = forwarding.egress.get(&egress_ifindex) else { | ||
| return SourceNatLookup::NoMatch; |
| Err(failure) => { | ||
| record_source_nat_failure( | ||
| telemetry, | ||
| worker_ctx, | ||
| meta, | ||
| flow, | ||
| from_zone_id, | ||
| to_zone_id, | ||
| desc.len, | ||
| &failure, | ||
| ); | ||
| binding.scratch.scratch_recycle.push(desc.addr); | ||
| continue; |
Add forwarding-level coverage for allocator-exhausted source NAT pools so exception identity is pinned for the pool exhaustion path, not only wrong-family or unavailable-pool cases.
5ebd76a to
35cd30d
Compare
Round-3 triple-review synthesis on
|
| Reviewer | Verdict |
|---|---|
| Claude | MERGE-READY |
| Codex | MERGE-READY |
| Gemini Pro 3 | MERGE-READY |
All three converge. r2 MINOR (allocator-exhausted forwarding test) is closed.
Codex confirmation
"The new test pins
SourceNatFailureReason::AllocatorExhaustedplusrule_name = 'exhausted'andpool_name = 'tiny-pool'. The fixture setspool_unusable: trueandpool_unusable_reason: 'allocator_exhausted', which maps throughparse_source_nat_rulestoSourceNatFailureReason::AllocatorExhausted."
Codex non-blocking note
"Forwarding-layer tests still do not separately cover
MissingPool,EmptyPool,InvalidPool, orInvalidPortRange. Those are covered at the NAT unit layer, and they share the same forwarding propagation path once they becomeSourceNatFailure, so I would not hold the PR for that."
Recommendation
Merge-ready. Both WrongAddressFamily (r2) and AllocatorExhausted (r3) — the runtime-only failure modes that can't be caught at the Go snapshot layer — now have explicit forwarding-test coverage.
Codex task: task-mpas41zd-w4c93j. Gemini task: task-mpas4d3o-pw4ix7. Not merging — author's decision.
* userspace: fail closed unusable SNAT pools * snat: include rule and pool identity in exceptions Add forwarding-level coverage for allocator-exhausted source NAT pools so exception identity is pinned for the pool exhaustion path, not only wrong-family or unavailable-pool cases.
Summary
pool_unusablereasons instead of dropping rule intentValidation
cargo test --manifest-path userspace-dp/Cargo.toml pool_snat_cargo test --manifest-path userspace-dp/Cargo.toml --test snat_contract_doc_guardgo test ./pkg/dataplane/userspace -run 'TestBuildSourceNATSnapshots'\n-git diff --check\n\nRefs Feature gap: address-persistent SNAT pool mode silently degrades to round-robin in userspace-dp #1377\n