felix/design/bpf-conntrack-flowstate.md
How flows are validated, recorded, and transitioned in BPF: BPF-side reverse-path filtering (RPF), the conntrack table with its forward/reverse pair convention and three-stage cleanup, the mid-flow fallthrough that lets BPF take over from *tables without breaking established connections, and the SkipFIB cooperation rule that makes 3rd-party DNAT in *tables interoperate with the BPF dataplane.
This is one of several sub-designs for the eBPF dataplane. See
bpf-overview.md for the packet-path mental
model, the fast-path cost rule, and the cross-cutting review notes
that apply to every BPF change. The full set of sub-designs is
listed in felix/DESIGN.md.
A large part of Calico's packet handling involves forwarding packets
directly with bpf_redirect, which bypasses the kernel's RPF check.
The kernel's per-interface rp_filter sysctl is also relaxed or
disabled on several Calico-managed interfaces — bpfnat, tunnel
devices — because the kernel would otherwise reject packets that
Calico has intentionally routed via unusual paths (bpf-host-networking.md → Host-networked workaround (bpfnat veth)).
The result is that the kernel cannot be trusted to enforce RPF for
Calico traffic. BPF therefore does it directly. See
felix/bpf-gpl/rpf.h.
For packets arriving on a workload veth, the check is simple and
always strict: look up the source IP in the BPF route table; the
route must point at the same workload interface the packet arrived
on, and the target must be a local workload. wep_rpf_check in
rpf.h.
Spoofing can be allowed per-(ifindex, source-IP) via the AllowSources
map when WORKLOAD_SRC_SPOOFING_CONFIGURED is set. When the check
passes via that bypass, the flag CALI_ST_SUPPRESS_CT_STATE is set
so that the conntrack entry isn't created with a bogus source — an
explicit decision to accept the packet but not to build a flow
record around it.
Host-endpoint RPF has to cope with the general routing table and so
cannot simply compare ifindices. hep_rpf_check uses the BPF FIB
helper with the source and destination swapped: if the FIB succeeds,
the packet has a valid reverse route; if the ifindex on that reverse
route equals the arrival ifindex, the check is strict-ok; otherwise
it is loose-ok (accepted in loose mode, rejected in strict mode).
RPF mode is a per-interface setting set by the RPFEnforceOption
field on the attach point and carried to BPF via the
CALI_GLOBALS_RPF_OPTION_ENABLED and CALI_GLOBALS_RPF_OPTION_STRICT
flags.
Special cases:
Running with loose BPF RPF on top of strict kernel RPF is not safe —
when rp_filter is non-zero on an interface that matters, the
kernel applies its own RPF before BPF's, and can drop a packet that
BPF intended to accept on one of the indirect routing paths. The
bpfnat veth (bpf-host-networking.md → Host-networked workaround (bpfnat veth)), the tunnel devices, and similar "packet arrives on
interface X but we expect the source to be routable via Y" paths all
depend on the kernel not second-guessing BPF.
Felix explicitly sets net.ipv4.conf.all.rp_filter = 0 when the
bpfnat feature is enabled; per-interface sysctls are set to loose
(2) on interfaces where Calico forwards packets that may appear
misrouted to the kernel. BPF does the real check.
RPFEnforceOption
accordingly. The default (RPF on) is right for most HEPs; tunnel
interfaces and bpfnat need bespoke handling.rp_filter sysctl.
Setting all.rp_filter = 1 anywhere else in the codebase breaks
bpfnat and tunnel paths.CALI_ST_SUPPRESS_CT_STATE
path so that the conntrack table does not fill up with entries
keyed on spoofed addresses.The BPF conntrack table (cali_v4_ct, cali_v6_ct) holds three kinds
of entry (TypeNormal, TypeNATForward, TypeNATReverse in
felix/bpf/conntrack/map.go):
CALI_CT_FLAG_SVC_SELF, CALI_CT_FLAG_MAGLEV).Creating a NAT'd flow means creating both. Destroying a NAT'd flow means destroying both — atomically enough that BPF never sees only one side. The cleanup pipeline is built around this requirement.
The forward entry is a stub: calico_ct_create_nat_fwd() fills in
only the type, the timestamp, the reverse key, and any source-port
rewrite. All connection state — the TCP legs, the flags, the RST
timestamp — lives on the reverse entry, which is why it is also called
the tracking entry, and is the single source of truth for the flow.
Readers and writers alike have to reach it: a NAT_FWD hit gives
calico_ct_lookup() the stub, so it redirects
src_to_dst/dst_to_src into the tracking entry's legs and uses
tracking_v for the fields hanging off the value itself.
A periodic sweep in Felix iterates the conntrack map and runs each
entry through a chain of EntryScanner instances
(felix/bpf/conntrack/scanner.go, scanners in
felix/bpf/conntrack/cleanup.go):
LivenessScanner — reads the entry's timestamp and per-protocol
timeout (timeouts.Timeouts), marks expired entries for deletion.
For NAT'd flows, bookkeeping lives on the reverse entry — the
forward-entry scanner follows the reverse-key pointer and decides
based on the reverse entry's timestamp. A forward entry with no
reverse counterpart is deleted immediately (it is useless without
the reverse).StaleNATScanner — for each NAT'd flow, checks whether the
service frontend still has the chosen backend. UDP stale-NAT
entries are deleted from userspace immediately, because
subsequent packets on the same flow would otherwise be forwarded
to a dead backend.WorkloadRemoveScannerTCP — receives workload-IP-removed
events from the BPF endpoint manager and, on the next sweep, marks
TCP flows involving those IPs for TCP reset rather than silent
deletion. The next packet on the flow triggers Felix to emit an
RST, so clients see the connection drop immediately rather than
hanging until the TCP timeout.The scanners return a ScanVerdict per entry:
ScanVerdictOK, ScanVerdictDelete, ScanVerdictSendRST.
Deleting entries directly from userspace is slow and creates a window
where the forward entry is gone but the reverse is not (or vice
versa). Felix therefore uses a BPF cleaner program
(felix/bpf-gpl/conntrack_cleanup.c, BPFProgCleaner in
felix/bpf/conntrack/bpf_scanner.go) for the common expired-entry
case:
cali_ct_cleanup, version cleanupv1). For a non-NAT entry the
record's forward and reverse keys are the same; for a NAT entry
the record captures both keys and both timestamps.The conntrack maps use an LRU hash backing, so if both the userspace scanners and the BPF cleaner fall behind and the map fills, the kernel evicts the oldest entries. This is a last-resort safety net, not a primary cleanup path — losing an active flow's CT entry produces a very visible application-level failure, so normal operation should never rely on it.
Conntrack entries carry kernel-time (CLOCK_MONOTONIC) timestamps
set by the BPF programs. Userspace caches the kernel-time translation
to avoid a per-entry clock_gettime overhead
(LivenessScanner.goTimeOfLastKTimeLookup). Any change to how
timestamps are stored needs to match on both sides — the BPF write
and the Go-side reader must agree on units and reference clock.
cali_v4_ct/cali_v6_ct have Version: 4 at the time of
writing). The kernel refuses to pin two layouts under the same
name, and older Felixes reading a newer map will misparse.ScanVerdictOK for no-op) and should be idempotent across
iterations. Scanners may be called once or many times per sweep
depending on how much Felix batches.cali_ct_cleanup) or accept the
race (and document why it is safe) — never delete a single side
from userspace.calico_ct_lookup() must go via tracking_v, not v — on a NAT_FWD hit
v is the forward stub, and state put there is silently never read.
Test such a change in the client→service direction; the reverse
direction hits the tracking entry directly and passes either way.*tables to eBPFWhen a running cluster switches Felix's dataplane from *tables to
BPF, three kinds of flow are at risk:
*tables
mode, and users expect the switch not to break that.Calico handles the first two with the "mid-flow fallthrough" pattern. The third is unavoidable and is accepted as a cost of the switch.
TCP is stateful: a non-SYN packet with no BPF conntrack hit is
unambiguously a mid-flow packet. BPF and *tables cooperate to let
it through:
CALI_SKB_MARK_FALLTHROUGH on the packet (bpf.h enum
calico_skb_mark) and returns TC_ACT_UNSPEC, letting the packet
continue into netfilter.InternalDataplane.bpfMarkPreestablishedFlowsRules in
int_dataplane.go) that matches packets on their Linux conntrack
state (ESTABLISHED/RELATED) and sets
CALI_SKB_MARK_CT_ESTABLISHED
(MarkLinuxConntrackEstablished = 0x08000000 in
felix/bpf/tc/defs/defs.go).CT_ESTABLISHED mark and lets the packet through without
re-running policy — the fact that Linux conntrack matched is
evidence that the flow was previously vetted.This gives correct behaviour for pre-existing flows as long as Linux conntrack still has their state. It also preserves connections that pre-date the Calico install entirely: same mechanism.
UDP does not signal flow establishment in the protocol, so BPF
cannot distinguish "a packet from a long-running UDP flow" from "a
fresh datagram". Any UDP packet that misses BPF CT is treated as new.
If the previous *tables dataplane had NAT'd the flow to a specific
backend, the BPF dataplane may now pick a different backend on first
sight. For protocols that care about backend affinity this manifests
as a brief disruption. The reference design document accepts this as
a valid tradeoff given UDP's delivery semantics; there is no
fallthrough shim for UDP.
Operator-based installs minimise the mixed-mode window by:
During the mixed window, BPF nodes and *tables nodes coexist and
inter-node traffic uses the *tables dataplane path (since the BPF
node's encap/decap expects its peer to understand the BPF wire
format, which *tables doesn't).
FALLTHROUGH mark, and (b) every subsequent BPF program that
sees a packet with CT_ESTABLISHED treats it as approved. Break
either and the switch starts dropping existing TCP connections.0x08000000) in sync with MarkLinuxConntrackEstablished in
felix/bpf/tc/defs/defs.go. The mask must include that bit
and not overlap with any other BPF-owned mark.Sometimes another agent on the host installs iptables/nftables DNAT rules to redirect traffic addressed to a host port to a workload port. A typical shape:
C:* -> H:hp (H = host, hp = host port).C:* -> W:wp (W = local workload,
wp = workload port).Without BPF this is straightforward: the packet hits the nat
PREROUTING chain, gets DNAT'd, and is routed to the workload. Return
traffic hits the nat POSTROUTING SNAT and the client sees the reply
from H:hp.
Two problems:
*tables raw-PREROUTING rules
set NOTRACK on workload traffic (this is how BPF takes over from
kernel conntrack). Kernel NAT requires tracked connections, so
a DNAT rule that would otherwise match gets skipped.Calico's raw-PREROUTING setup in
felix/rules/static.go (search for MarkSeenSkipFIB) installs a
rule that sets the SkipFIB skb mark on any packet whose
destination is the local host:
match: destination addrtype=LOCAL
action: set mark 0x01100000/0x01100000 (CALI_SKB_MARK_SKIP_FIB)
comment: "Mark traffic towards the host - it is TRACKed"
The mark is tcdefs.MarkSeenSkipFIB, which equals
CALI_SKB_MARK_SKIP_FIB on the BPF side
(see felix/bpf-gpl/bpf.h enum calico_skb_mark). Because this
happens in raw-PREROUTING, it runs before any DNAT chain, so the
destination is still the host IP at match time.
What this buys:
SKIP_FIB mark and, on conntrack entry
creation, copies it into the CT entry as
CALI_CT_FLAG_SKIP_FIB (defined in
felix/bpf-gpl/conntrack_types.h, mirrored in
felix/bpf/conntrack/v4/map.go as FlagSkipFIB).bpf_redirect'd — so the host nat
POSTROUTING chain (including Calico's own MASQ and the 3rd-party
SNAT counterpart) runs normally.*tables. Changes to the rule-generator layer must keep
this rule and must keep its addrtype=LOCAL match — a broader
match unnecessarily forces the host stack for traffic that BPF
could forward, a narrower match breaks 3rd-party DNAT.CALI_CT_FLAG_SKIP_FIB must be preserved on conntrack writes that
refresh or update an entry. Losing the flag on a subsequent packet
would re-enable FIB for the return leg and break the 3rd-party
DNAT's return path.0x01100000
region must confirm it doesn't overlap with SKIP_FIB or the
other bits the raw-PREROUTING rule sets — the whole mark word
is routed back into BPF.A change to how the BPF dataplane works in the area this file covers must update the relevant section in the same PR — new mechanism, new flag, new map field, new config knob, or any change to the packet path. Exemptions: (a) bug fix restoring documented behaviour, (b) mechanical refactor with no observable change, (c) comment / log-message edits, (d) dependency bumps. If in doubt, update.
Cross-cutting rules that apply to every BPF change (map
versioning, mark discipline, sub-program registration, kernel-
version sensitivity) live in
bpf-overview.md → Cross-cutting review notes.