<!--
Copyright (c) 2026 Tigera, Inc. All rights reserved.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->

# eBPF dataplane — TC program layout

How the per-interface BPF programs are organised: the attach mechanisms (clsact, TCX, netkit), how one is selected and how a device moves between them, 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`](./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`](../DESIGN.md).

## TC program layout

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).

### Attach mechanisms (clsact, TCX, netkit)

The same packet-processing programs are attached via one of three
kernel mechanisms, selected per interface:

- **Legacy clsact (TC)** — the classic `BPF_PROG_TYPE_SCHED_CLS`
  on the per-interface clsact ingress/egress filter. The fallback
  on kernels that do not support TCX.
- **TCX** — preferred when the kernel supports it. Same program
  type as clsact, attached via `bpf_link` to the per-interface
  TCX hook.
- **Netkit, workload interfaces only** — when the workload
  interface is a netkit device (rather than a veth), Felix
  attaches via the netkit attach API, with `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.

`BPFAttachType` selects between them. Its default, `Netkit`,
means netkit where it applies and TCX everywhere else; `TCX` and
`TC` name a single mechanism and so opt the node out of netkit
attachment. `tc.ResolveAttachType` turns the option into a
mechanism — only ever TC or TCX, carried on every attach point —
plus whether netkit attachment is allowed. The internal
`AttachPoint.Netkit` signal stays separate from the enum so the
attach layer remains two-valued.

A netkit-enabled cluster is not a wholesale swap. Felix selects
the attach mechanism per attach point at attach time
(`doApplyPolicy` 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.

### Leaving netkit attachment

Selecting `TCX` or `TC` restarts Felix and moves existing netkit
devices onto that mechanism at start of day; the devices stay
netkit and no pod is recreated. Three cleanup points make the
switch safe:

- `cleanUpNetkitAttach` (`felix/bpf/tc/attach.go`) drops the
  leftover netkit link, mirroring the netkit path's cleanup of
  leftover TC/TCX state. Without it the device would run both
  dataplanes at once, each with its own policy and conntrack
  state. Detaching is safe because a netkit pair created with
  `NETKIT_POLICY_FORWARD` and no programs forwards like a veth.
- Jump-map indices are returned to whichever allocator issued them;
  the netkit and TC/TCX allocators are disjoint. At start of day
  which one issued them is read from the netkit link pin, not the
  device type — the device is still netkit while the mechanism may
  already have changed.
- `ensureQdisc` keys off the mechanism, not the link type, so a
  TC-driven device gets the qdisc it now needs.

The packet-handling code is mechanism-agnostic — same preamble,
same jump-map layout, same policy program — with four
netkit-specific concessions:

- **Separate jump maps.** Netkit programs have a different
  `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).
- **No `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 `doApplyPolicy` in
  `bpf_ep_mgr.go`, alongside the rest of the netkit override, so
  a device driven by TC/TCX gets `bpf_redirect_peer` back.
- **Synchronous detach on cleanup.** `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 preamble

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:

1. Pick the IPv4 or IPv6 globals struct based on `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.
2. If a log filter is configured for this interface, tail-call it.
3. Otherwise tail-call the "main" packet-processing program directly.

XDP has an equivalent preamble in `xdp_preamble.c`. The cgroup
connect-time hooks ([bpf-services.md → Connect-Time Load Balancer (CTLB)](./bpf-services.md)) 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.

Felix keeps, in memory, the globals (jump tables included) each hook's
workload preamble was attached with, and re-attaches when the globals
it would now write differ or it finds no qdisc or attached classifier.

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 lockdown state lives on securityfs, which Felix mounts
read-only into its own mount namespace for that one read rather than
taking it as a host mount — a host mount would make `CONFIG_SECURITYFS`
a hard requirement for starting `calico-node` at all. The mount needs
`CAP_SYS_ADMIN` in the initial user namespace; when it fails, Felix
assumes the kernel is not locked down.

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.

### Two-tier jump maps

Packet-processing programs are organised into two jump maps per
direction:

- **Generic programs map** — `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`).
- **Per-endpoint programs map** — `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](./bpf-observability.md)). 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.

### Caching of generic programs

`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.

Whether the device encapsulates is deliberately *not* part of it. It is
per-attach-point runtime state (the `IFACE_ENCAPS` global), so Calico's
own overlay devices share the host object rather than each needing one.

`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.

### skb->cb[0] and skb->cb[1]: continuation addresses

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:

- **Policy is callable from any generic program.** The generic
  callers — main, Maglev, new-flow — each know which jump-map
  indices they want to continue at, write them into `cb[]`, then
  tail-call the single per-endpoint policy program. No per-caller
  specialisation of policy is needed.
- **The debug path reuses the same policy program as the fast path.**
  The fast-path caller writes fast-path continuation indices; the
  debug-path caller writes debug-path continuation indices. Policy
  itself doesn't need a debug variant — the continuation passed in
  `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.

### Fast path and debug path

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](./bpf-observability.md).

### Attach-gap prevention

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.

### Review notes for this section

- A new sub-program added to the generic program chain needs:
  - an entry in `enum cali_jump_index` in `felix/bpf-gpl/jump.h` with
    a matching `_DEBUG` variant,
  - a corresponding `SubProg*` constant and name in
    `felix/bpf/hook/map.go` (`tcSubProgNames` / `xdpSubProgNames`),
  - filtering in `GetApplicableSubProgs` if the program is not needed
    for every `AttachType`.
- A new per-endpoint program (something Felix reprograms per
  interface) belongs in the per-endpoint jump map (`cali_jump_*`),
  not the generic one. Generic programs are shared; per-endpoint
  programs are not.
- Any caller that tail-calls into the policy program must set
  `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.
- A change that adds a new field to the `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.
- A change that makes BPF forward a packet directly that used to go
  through `*tables` should consult `fib_approve` (or an equivalent
  check) for the ifstate-ready flag; otherwise it reopens the
  attach-gap hole.
- A path that skips the destination endpoint's program must not treat
  `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.
- Helpers and maps keyed by the host-side ifindex must read
  `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.
- Code paths that depend on TC ingress context (helpers that
  check `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.
- Every generic sub-program in the chain must be reachable
  through both the TC/TCX jump maps and `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.




---

## Cross-cutting rules

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](./bpf-overview.md).
