Gap
The eBPF dataplane implements address-persistent SNAT pool mode (Junos pool-name { address-persistent; }): the pool address selected for a given internal source IP is sticky — re-NAT'd flows from the same internal source always pick the same pool address. The userspace dataplane parses the same pool_addresses snapshot field but its PortAllocator does round-robin only — the addr_persistent semantic is silently lost. This is a CORRECTNESS regression for any deployment relying on sticky external-IP affinity (e.g. SIP, stateful upstream auth tied to client IP).
eBPF implementation (source of truth)
bpf/xdp/xdp_policy.c:442-445 — if (cfg->addr_persistent) ip_idx = ((__u32)src_ip) % cfg->num_ips; (IPv4 sticky path)
bpf/xdp/xdp_policy.c:527-535 — same for IPv6: hash last 4 bytes of source for sticky pool index
pkg/dataplane/compiler_nat.go:306, 419 — if natCfg.AddressPersistent { ... } propagates the flag into BPF nat_pool_config
bpf/headers/xpf_nat.h (struct nat_pool_config { __u8 addr_persistent; ... })
Userspace-dp gap
userspace-dp/src/nat.rs:107-114 PortAllocator::next_address_index():
pub(crate) fn next_address_index(&self) -> usize {
if self.counters.is_empty() { return 0; }
let idx = self.addr_counter.fetch_add(1, Ordering::Relaxed);
(idx as usize) % self.counters.len()
}
grep -rn 'address.persistent\\|address_persistent\\|AddressPersistent' userspace-dp/src/ — zero matches. grep -rn 'address_persistent\\|AddressPersistent\\|addr_persistent' pkg/dataplane/userspace/snapshot.go — zero matches. The Go-side snapshot does not even carry the flag to Rust.
Recommended fix
- Plumb
AddressPersistent bool through SourceNATRuleSnapshot in pkg/dataplane/userspace/protocol.go + matching field on the Rust SourceNATRuleSnapshot
- In
userspace-dp/src/nat.rs::PortAllocator, add next_address_index_for(src_ip: IpAddr) that returns hash(src_ip) % num_ips when sticky, else falls back to round-robin
- Add capability gate or test that
address-persistent config actually round-trips correctly under userspace mode
Blocker for #1373
This must land before Phase 4 (BPF source removal) of #1373. Today the eBPF dataplane preserves correctness for these configs; once eBPF is removed there is no fallback path and the silent regression becomes permanent.
Refined contract (added 2026-05-17 after triple-review of #1384 and code review of #1385)
See docs/pr/1373-retire-ebpf-dataplane/plan-1377-snat-pools.md for the full implementation contract. New since the original issue body:
Gap
The eBPF dataplane implements address-persistent SNAT pool mode (Junos
pool-name { address-persistent; }): the pool address selected for a given internal source IP is sticky — re-NAT'd flows from the same internal source always pick the same pool address. The userspace dataplane parses the samepool_addressessnapshot field but itsPortAllocatordoes round-robin only — theaddr_persistentsemantic is silently lost. This is a CORRECTNESS regression for any deployment relying on sticky external-IP affinity (e.g. SIP, stateful upstream auth tied to client IP).eBPF implementation (source of truth)
bpf/xdp/xdp_policy.c:442-445—if (cfg->addr_persistent) ip_idx = ((__u32)src_ip) % cfg->num_ips;(IPv4 sticky path)bpf/xdp/xdp_policy.c:527-535— same for IPv6: hash last 4 bytes of source for sticky pool indexpkg/dataplane/compiler_nat.go:306, 419—if natCfg.AddressPersistent { ... }propagates the flag into BPF nat_pool_configbpf/headers/xpf_nat.h(structnat_pool_config { __u8 addr_persistent; ... })Userspace-dp gap
userspace-dp/src/nat.rs:107-114PortAllocator::next_address_index():grep -rn 'address.persistent\\|address_persistent\\|AddressPersistent' userspace-dp/src/— zero matches.grep -rn 'address_persistent\\|AddressPersistent\\|addr_persistent' pkg/dataplane/userspace/snapshot.go— zero matches. The Go-side snapshot does not even carry the flag to Rust.Recommended fix
AddressPersistent boolthroughSourceNATRuleSnapshotinpkg/dataplane/userspace/protocol.go+ matching field on the RustSourceNATRuleSnapshotuserspace-dp/src/nat.rs::PortAllocator, addnext_address_index_for(src_ip: IpAddr)that returnshash(src_ip) % num_ipswhen sticky, else falls back to round-robinaddress-persistentconfig actually round-trips correctly under userspace modeBlocker for #1373
This must land before Phase 4 (BPF source removal) of #1373. Today the eBPF dataplane preserves correctness for these configs; once eBPF is removed there is no fallback path and the silent regression becomes permanent.
Refined contract (added 2026-05-17 after triple-review of #1384 and code review of #1385)
See
docs/pr/1373-retire-ebpf-dataplane/plan-1377-snat-pools.mdfor the full implementation contract. New since the original issue body:src_ip % num_ips; legacy eBPF IPv6 XORs the four 32-bit source-address lanes mod pool size; current userspace usesxpf-userspace-snat-address-persistent-v1SHA-256 over(family_tag, src_octets); DPDK has allocator-local internals. Feature gap: address-persistent SNAT pool mode silently degrades to round-robin in userspace-dp #1377 must either standardize a shared algorithm or document a compatibility break + constrain mixed-backend failover/rollback tests. Until then, "address-persistent" means stable within one backend's pool order/size only.source address-persistent(per-source-IP sticky pool address) ≠ per-poolpersistent-nat(per-flow sticky NAT mapping). Feature gap: address-persistent SNAT pool mode silently degrades to round-robin in userspace-dp #1377 must implement both, not just the first.(backend, source_address, pool_family, pool_order); any cross-backend divergence captured in tests and docs.