felix/design/bpf-tc-programs.md
How the per-interface BPF programs are organised: the attach mechanisms (clsact, TCX, netkit) and the netkit-specific concessions that follow, the per-interface preamble, the two-tier jump maps that decouple per-endpoint policy from generic packet-handling, the skb->cb allow/deny convention, and the fast/debug path machinery. Also covers the cali_iface ifstate map and the attach-gap protection it enables.
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.
Attaching a TC program directly per interface does not scale: Felix would have to reload the full program set every time policy changes and every time it restarts. The current design decouples the programs that rarely change (the packet-handling code) from the programs that frequently change (per-endpoint policy).
The same packet-processing programs are attached via one of three kernel mechanisms, selected per interface:
BPF_PROG_TYPE_SCHED_CLS
on the per-interface clsact ingress/egress filter. The fallback
on kernels that do not support TCX.bpf_link to the per-interface
TCX hook.BPF_NETKIT_PRIMARY
for the host-side (to-pod, TC-egress equivalent) program and
BPF_NETKIT_PEER for the peer (from-pod, TC-ingress
equivalent) program. Felix detects netkit support at runtime
(tc.IsNetkitSupported in felix/bpf/tc/attach.go) and only
uses netkit attachment for the workload interfaces it
manages — host or data-plane netkit devices are not Felix's
concern. The internal signal AttachPoint.Netkit is set
separately from the user-facing BPFAttachType enum so that
the override is scoped to Felix's own detection.A netkit-enabled cluster is not a wholesale swap. Felix selects
the attach mechanism per attach point at attach time
(calculateTCAttachPoint in bpf_ep_mgr.go): TC clsact or TCX
for HEPs, tunnels, the bpfnat and loopback pair, and any workload
interface that is still a regular veth; netkit only for workload
interfaces that are themselves netkit devices. A single Felix
process therefore programs both styles concurrently — the
supporting machinery (jump-map sets, globals plumbing, cleanup
paths) handles both in parallel rather than switching wholesale
when netkit is enabled.
The packet-handling code is mechanism-agnostic — same preamble, same jump-map layout, same policy program — with four netkit-specific concessions:
expected_attach_type from TC/TCX; the kernel rejects a
prog_array that mixes the two. Felix maintains a parallel set
of prog_array maps for netkit, pinned under
bpfdefs.NetkitPinDir (/sys/fs/bpf/netkit) instead of the
TCX directory. See NetkitJumpMaps in
felix/bpf/bpfmap/bpf_maps.go and netkitPinOverrides in
felix/bpf/hook/map.go. The endpoint manager carries a
matching netkitJumpMapAllocs allocator alongside the TC one
in felix/dataplane/linux/bpf_ep_mgr.go.host_ifindex global. A netkit peer program runs in the
pod's network namespace, so skb->ifindex on a peer-program
call is the peer ifindex, not the host-side one. Felix
populates host_ifindex in the per-attach-point globals
(felix/bpf-gpl/globals.h). Helpers keyed by host-side
ifindex — wep_rpf_check in rpf.h, the QoS map lookup in
qos.h, the per-interface counters macro in types.h, and
the FIB redirect path in fib_co_re.h — read host_ifindex
first and fall back to skb->ifindex only when it is zero
(the TC/TCX case).bpf_redirect_peer. The helper requires
skb_at_tc_ingress context; netkit programs run in xmit
context where the helper silently drops the packet. Felix
forces RedirectPeer = false on netkit attach points so the
FIB path uses plain bpf_redirect. Set in
calculateTCAttachPoint in bpf_ep_mgr.go.detachAndRemoveLinkPins
in felix/bpf/tc/cleanup.go opens each pinned link, calls
Detach(), and only then unlinks the pin file. The same
shape applies to TCX cleanup; relying on the kernel's
auto-detach when the last reference closes is not enough.The program that is actually attached to the TC hook on each
interface is a preamble
(felix/bpf-gpl/tc_preamble.c → cali_tc_preamble). It is tiny, fast
to load, and its only job is to:
skb->protocol and
copy it into per-CPU state. The rest of the program chain reads
globals from that state, so a single copy amortises the cost.XDP has an equivalent preamble in xdp_preamble.c. The cgroup
connect-time hooks (bpf-services.md → Connect-Time Load Balancer (CTLB)) are attached directly — they have no preamble.
Because the preamble is cheap to reload, Felix can swap it per
interface without re-verifying the large program chain it fronts — and
must, since it bakes per-interface config (jump-map indices, host IP,
flags) into .rodata, some of which changes without a restart. So the
preamble is re-loaded, and re-verified, on every attach.
The preamble calls bpf_trace_printk on its drop/error paths
regardless of BPFLogLevel. Under kernel lockdown=confidentiality
ftrace is disabled, so every load makes the kernel log could not enable bpf_trace_printk events. Felix detects this at startup
(bpf.KernelLockdownConfidentiality) and instead loads
trace-printk-free preamble variants (*_notrace.o,
AttachPoint.NoTracePrintk), forcing BPFLogLevel: Debug off on such
nodes.
The main programs avoid _notrace duplicates (which would double the
program matrix): each carries a struct prog_flags in its own frozen
.rodata.prog_flags section. Felix sets no_trace_printk there before
load (bpf.SetNoTracePrintk → Map.SetProgFlags); the frozen constant
lets the verifier fold the flag and dead-code-eliminate skb_log (the
policy Log action, which references the trace helper regardless of
BPFLogLevel). Per-program, load-time flags belong in struct prog_flags — it is the vehicle for them, distinct from the node-wide
globals.
Packet-processing programs are organised into two jump maps per direction:
cali_progs_ing (TC ingress),
cali_progs_egr (TC egress), xdp_cali_progs (XDP). Holds the
code that is the same across all endpoints of the same kind: the
main program, allowed/drop entry points, ICMP helpers, IP-frag
helper, Maglev helper, TCP reset helper, etc. See
felix/bpf/hook/map.go (tcSubProgNames, xdpSubProgNames).cali_jump_ing, cali_jump_egr,
xdp_cali_jump. Holds the policy program generated for each
interface+direction, and the pcap log filter if one is installed
(see bpf-observability.md → Debug log filters). The policy program is regenerated and re-loaded
whenever the rules for that endpoint change; the generic code above
is untouched.The split is what lets policy updates be cheap: Felix only has to rewrite a couple of entries in the per-endpoint map, not replace any attached programs. The kernel's tail-call mechanism makes the switch atomic per-program-index.
Splitting the two TC directions (ingress vs egress) into separate maps
is a kernel-6.12 requirement (TCX programs on each direction have a
distinct program type and a jump map is typed for a single program
type). See the comment at the top of
felix/bpf-gpl/jump.h.
Both maps are sized generously. The per-endpoint map is large (240k
slots) because each endpoint gets two policy programs (one per TC
direction). Policy programs themselves are not duplicated for the
fast and debug paths — one compiled policy program serves both, and
the caller's continuation (encoded in skb->cb[0]/cb[1], below)
is what decides which path the packet continues on. A handful of the
generic sub-programs in the other map (main, allowed, drop, ICMP,
etc.) do have separate fast and debug variants occupying distinct
slots; see allocateLayout in felix/bpf/hook/map.go for the
placement — it uses an offset for debug variants but leaves
SubProgTCPolicy at a single index.
hook.ProgramsMap (in felix/bpf/hook/map.go) caches one loaded
object file per AttachType. An AttachType is the tuple of flags
that changes which sub-programs are compiled in: hook (TC
ingress/egress or XDP), whether the interface is a WEP, HEP, tunnel,
DSR-enabled, cgroup, etc. LoadObj loads the object at most once per
AttachType and returns a Layout that maps every sub-program to the
jump-map index it was placed at.
GetApplicableSubProgs filters the list based on capability: the
host-CT-conflict helper is only loaded for HEP egress, the Maglev
helper only where Maglev can be used, the IP-frag helper only on
attach points that run defrag. If the kernel rejects the IP-frag
program (older verifiers can fail on it), the load is retried with
that sub-program disabled — which is the only sub-program that may
legitimately be missing at runtime.
The per-endpoint policy program is called from generic (per-endpoint-independent) programs. The convention is that before tail-calling into policy, the caller writes:
skb->cb[0] = the jump-map index to tail-call on allow,skb->cb[1] = the jump-map index to tail-call on deny.Policy reads those and tail-calls into the appropriate successor. Two things fall out of this convention:
cb[], then
tail-call the single per-endpoint policy program. No per-caller
specialisation of policy is needed.cb[] is what carries the path selection.The convention is encoded in __CALI_JUMP_TO_POLICY in jump.h: it
defaults to cb[0] = PROG_INDEX_ALLOWED, cb[1] = PROG_INDEX_DROP,
but callers that want a different continuation (for example the
Maglev program, which wants to re-enter the main program on allow)
write their own values before jumping.
Emitting a log line per packet is far too expensive for production,
so the dataplane is built twice: once without log statements ("fast
path") and once with them enabled ("debug path"). Each sub-program
therefore has two entries in enum cali_jump_index in jump.h, for
example PROG_INDEX_MAIN and PROG_INDEX_MAIN_DEBUG. The
PROG_PATH(idx) macro selects at compile time based on
CALI_LOG_LEVEL.
Both the fast-path and debug-path objects are loaded into the same
jump map, at different indices. allocateLayout in
felix/bpf/hook/map.go uses an offset of SubProgTCMainDebug when
placing debug programs, so fast-path and debug-path sub-programs never
collide.
Path selection depends on BPFLogLevel:
BPFLogLevel is not debug: only the fast path is loaded. The
preamble jumps directly to the fast-path main.BPFLogLevel == debug and no user-specified filter: both paths
are loaded and a match-all log filter is installed. Every
packet takes the debug path. Using a match-all filter (rather
than special-casing "no filter") keeps the preamble logic
uniform.BPFLogLevel == debug with a user-specified filter: both paths
are loaded; the filter decides per-packet whether each packet
takes the fast or debug path. Log filtering is covered in bpf-observability.md → Debug log filters.An interface exists briefly before Felix attaches a BPF program to it.
If another interface's BPF program forwarded a packet to the unready
one via bpf_redirect, the packet would bypass the *tables drop
rules that Felix maintains for this window. To prevent this, Felix
maintains the cali_iface hash map (key: ifindex,
felix/bpf/ifstate/map.go and felix/bpf-gpl/ifstate.h). The value
carries per-interface flags — notably IFACE_STATE_V4_READY /
IFACE_STATE_V6_READY and IFACE_STATE_WEP / IFACE_STATE_HEP —
plus the per-direction policy jump-map indices.
fib_approve (in felix/bpf-gpl/fib_common.h) consults this map
before approving a direct forward. For any connection that is not yet
confirmed in conntrack, a forward to a WEP whose interface is not
ready is refused; the packet is marked CALI_SKB_MARK_SKIP_FIB and
handed to the host stack, where the existing *tables attach-gap
drop rules apply. Confirmed (already-established) flows are allowed
through directly — the policy check happened when the flow was
created.
bpf_redirect_peer (in try_redirect_to_peer,
felix/bpf-gpl/fib_co_re.h) is a stronger form of the same forward: it
delivers into the destination's network namespace, so the destination's
program does not run at all. It is gated on the conntrack verdict being
CALI_CT_ESTABLISHED_BYPASS, which means both endpoints approved their
own leg.
That gate assumes the endpoint that approved a leg is the endpoint the packet will keep reaching, and while routing is still converging it is not. A packet can be forwarded to one endpoint, be approved by it, and then — once the route it was missing lands — be retransmitted to a different endpoint that has never seen it. An approval granted by whoever happened to be on the path becomes an approval on behalf of whoever ends up receiving the traffic.
tc.c already guards against exactly this by forcing policy on every
TCP SYN, so the retransmitted SYN would be re-evaluated by its real
destination. The peer redirect defeats that guard, because it removes
the program that would apply it. Initial SYNs are therefore excluded
from the peer redirect; established traffic still takes the fast path.
is_tcp_syn() in felix/bpf-gpl/conntrack.h is the single spelling of
that question, shared with the force-policy path, the ingress connlimit
counter, the Istio DSCP mark, and the withholding of the bypass mark. It
reads CT_RES_SYN, which calico_ct_lookup() sets from the packet's own
flags and only on a lookup hit, so it answers "SYN on a flow we already
track" — which is what each of those callers wants.
enum cali_jump_index in felix/bpf-gpl/jump.h with
a matching _DEBUG variant,SubProg* constant and name in
felix/bpf/hook/map.go (tcSubProgNames / xdpSubProgNames),GetApplicableSubProgs if the program is not needed
for every AttachType.cali_jump_*),
not the generic one. Generic programs are shared; per-endpoint
programs are not.skb->cb[0] and skb->cb[1] to valid jump-map indices first.
Calling the policy program without setting them produces
hard-to-debug drops on the allow or deny path.cali_iface value — or a new
flag — needs a map-version bump (see MapParams.Version in
felix/bpf/ifstate/map.go) because the kernel refuses to pin two
different layouts under the same name.*tables should consult fib_approve (or an equivalent
check) for the ifstate-ready flag; otherwise it reopens the
attach-gap hole.CALI_CT_ESTABLISHED_BYPASS as proof that this destination ran
policy. A leg can have been approved by a different endpoint the
packet reached earlier, while routing was still converging. Any such
path needs its own new-connection check — for TCP that is an initial
SYN; UDP has no equivalent and needs a different signal.host_ifindex from globals first and fall back to
skb->ifindex only when it is zero. Reading skb->ifindex
directly works for TC/TCX but on a netkit peer program keys
the lookup on the peer ifindex, which the host-side maps
don't know about.skb_at_tc_ingress, etc.) must be gated off for netkit
attach points. bpf_redirect_peer is the existing example,
gated via RedirectPeer = false on netkit. A path that takes
a similar context dependency without an equivalent gate will
silently misbehave on netkit.NetkitJumpMaps. The
shared hook.ProgramsMap machinery handles this when the
sub-program is registered via tcSubProgNames and the
per-direction MapPinOverrides are honoured in the
AttachPoint — bypassing that machinery (e.g. pinning into a
specific path) silently breaks netkit.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.