<!--
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 — Observability surface

How the dataplane reports and tags traffic for external consumers: pcap-expression debug log filters with their fast/debug dual-path mechanism, per-flow events emitted into a ring buffer (flow logs, distinct from per-packet debug logs), QoS controls in BPF (packet-rate enforcement, DSCP marking) and the Istio ambient-mode integration that uses DSCP at TCP SYN time to signal in-mesh traffic to ztunnel.

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

## Debug log filters

With `BPFLogLevel = debug` every program logs at every interesting
point in the packet path, which overwhelms a loaded node. BPFLogFilters
target debug logging at a pcap-selected subset of packets, so only
that traffic pays the cost.

### Fast path and debug path

The dataplane is compiled twice:

- The **fast path** has all logging calls optimised out
  (`CALI_LOG_LEVEL < DEBUG`). This is what runs on every packet in
  production.
- The **debug path** is the same code with logging enabled
  (`CALI_LOG_LEVEL == DEBUG`). Each sub-program (main, policy,
  allowed, drop, etc.) has a `_DEBUG` variant in
  `enum cali_jump_index` ([bpf-tc-programs.md → TC program layout](./bpf-tc-programs.md)).

When no filter is configured, only the fast path is loaded. When a
filter is configured, both paths are loaded: same map, different
indices, thanks to the `SubProgTCMainDebug` offset in
`allocateLayout` (`felix/bpf/hook/map.go`).

### pcap → eBPF

The filter itself is a BPF program compiled from a pcap expression.
Doing the compilation at runtime is much cheaper than hand-assembling
a filter per rule, and reuses the familiar pcap language.
Implementation in `felix/bpf/filter/filter.go`:

1. `pcap.CompileBPFFilter` (gopacket/pcap) turns the expression into
   classic-BPF (cBPF) instructions.
2. `cBPF2eBPF` converts those to eBPF bytecode in our
   `felix/bpf/asm/asm.go` representation.
3. The filter's prologue copies packet bytes into a per-CPU scratch
   buffer (the state map) so the cBPF offsets resolve correctly; the
   epilogue either tail-calls the debug-path main (on match) or
   falls through to the fast-path main (on miss).

The filter uses the same `skb->cb[0]` / `skb->cb[1]` convention as
every other tail-caller ([bpf-tc-programs.md → TC program layout](./bpf-tc-programs.md)):

- `skb->cb[0]` = fast-path main index (no match → go here),
- `skb->cb[1]` = debug-path main index (match → go here).

### Loops and `ip6 protochain`

`ip6 protochain`, which walks the IPv6 extension-header chain, compiles to
a loop with a backward `BPF_JA` that the eBPF verifier rejects. `cBPF2eBPF`
**unrolls** it: a back-edge becomes a forward jump into the next of
`maxLoopUnroll` copies of the loop body, and the last copy falls through to
`miss` (chain longer than the bound ⇒ no match). This is a bounded walk of
the extension-header chain, like the dataplane's own IPv6 header parsing in
`felix/bpf-gpl/parsing6.h`. Only a single reducible loop is supported;
anything more complex is rejected at compile time.

### Integration with the preamble

`tc_preamble.c` checks `globals->data.log_filter_jmp`. If it is not
`-1`, the preamble sets up `skb->cb[0]` and `skb->cb[1]` and
tail-calls into the filter; otherwise it jumps directly to the
fast-path main. The per-endpoint jump map (`cali_jump_prog_map`)
holds the filter, not the generic program map — filters are
per-interface.

### BPFLogFilters and bpfCTLBLogFilter

- `BPFLogFilters` is a comma-separated list of `key=value` entries.
  The key is an interface name, `all`, `hep` or `wep`; the value is
  the pcap expression. This lets an operator attach different
  filters to different interfaces or to whole classes of
  interfaces.
- `bpfCTLBLogFilter` is separate because CTLB programs run inside
  syscalls and have no packet to match against a pcap expression.
  The CTLB filter is effectively a boolean "do/don't log"
  per-CTLB-hook; the docstring in `config-params.json` notes that
  it must be `all` to see CTLB logs when `BPFLogFilters` is set,
  so that one knob doesn't accidentally silence the other.

### Review notes for this section

- A change that adds a new BPF sub-program to the path must add a
  matching `_DEBUG` variant in `enum cali_jump_index` and keep the
  fast/debug offset in `allocateLayout` consistent. Otherwise the
  debug path cannot reach the new program.
- Any change to `skb->cb[0]`/`skb->cb[1]` semantics must be
  reflected in the filter compiler's epilogue
  (`programFooter` in `filter.go`) — the filter and the main
  programs have to agree on which slot is "allow" and which is
  "deny/fast".
- A PR that introduces a new type of filter target (for example,
  filtering by metric rather than pcap expression) should reuse the
  dual-path loading scheme rather than inventing a new one. The
  machinery to load two path-variants of every sub-program is
  already there; duplicating it across filter types is how this
  area becomes unmaintainable.
- `maxLoopUnroll` in `filter.go` is a correctness/size trade-off, not a
  free knob: every increment replicates the whole loop body once more in
  every filter that contains a loop. It is intentionally smaller than the
  extension-header bound the C dataplane walks (`felix/bpf-gpl/parsing6.h`,
  which goes up to 8) — a debug log filter does not need to follow every
  chain to the end. Raise it only with a concrete need.

## Flow logs & event ring buffer

### What it is, and what it is not

Flow logs are **per-flow events** (one event per flow start /
flow end / flow update), emitted by the BPF programs into a
ring buffer and consumed in userspace. They feed Calico's flow
log / observability pipeline (Goldmane and friends).

They are distinct from:

- **[Debug log filters](#debug-log-filters)**, which emit per-packet textual
  traces for diagnosis. Log filters are off by default; flow
  logs are a feature.
- **BPF counters**, which produce aggregate counts, not
  per-flow records.

The mechanisms share nothing, so a change to one does not reach the
others.

### Enablement

Flow logs are gated globally by the `FLOWLOGS_ENABLED` flag
(`felix/bpf-gpl/cali_bpf.h` / `globals.h`, bit
`CALI_GLOBALS_FLOWLOGS_ENABLED`). Set per-attach-type through
the `FlowLogsEnabled` field on the AttachPoint. When the flag
is off, the emission paths in the BPF programs are compiled to
no-ops via the runtime flag check; no per-packet cost.

### Emission path

The main BPF programs (`tc.c`) call the flow-log emit helpers
at well-defined flow events:

- **Flow start** — on creating a new CT entry (new allowed
  flow).
- **Flow end / close** — when conntrack deletes the flow,
  either via the scanner's expiry ([bpf-conntrack-flowstate.md → Conntrack & cleanup](./bpf-conntrack-flowstate.md)) or because a RST/FIN
  has been observed.
- **Denies and mid-flow policy verdicts** — when a packet is
  dropped for a policy reason we want recorded.

Events are written to a **BPF ring buffer**
(`felix/bpf-gpl/ringbuf.h`; userspace reader in
`felix/bpf/ringbuf/`) with a structured event header (see
`events.h` / `events_type.h` in `bpf-gpl`, consumed by
`felix/bpf/events/`). The event carries the 5-tuple, the
conntrack flags at the time of the event, packet and byte
counters, timestamps and a verdict code.

A ring buffer (`BPF_MAP_TYPE_RINGBUF`) rather than a per-CPU perf
buffer: it is multi-producer single-consumer, so userspace reads one
stream, and its drops are explicit and countable.

### Review notes

- A change to the event struct is a wire-format change between
  BPF and userspace. The reader in `felix/bpf/events/` and any
  downstream collector (Goldmane, syslog shipper) need to be
  updated in step.
- New event types go in `events_type.h` (new enum value) and
  need a handler on the reader side. Emitting an unknown type
  leaves it at "ignored" in userspace — silent data loss.
- A new emission _call site_ should be gated on
  `FLOWLOGS_ENABLED` so an operator who disables the feature
  does not pay for it.
- Do **not** emit events on every packet of an established
  flow. Emission lives on the flow-creation path, and the
  `FLOWLOGS_ENABLED` guard is a single load, so flow logs cost the
  fast path nothing
  ([bpf-overview.md → Fast-path performance discipline](./bpf-overview.md));
  keep it that way.

## QoS

### Scope of BPF involvement

Calico's QoS controls cover bandwidth, packet rate, connection count
and DSCP. The BPF dataplane handles three of the four:

- **Bandwidth** is handled by tc qdiscs on the workload veth and by
  the upstream CNI bandwidth plugin. BPF is not involved.
- **Packet rate** is enforced by BPF (per-workload token bucket).
- **Connection count** (`IngressMaxConnections` / `EgressMaxConnections`
  on a workload endpoint) is enforced by BPF in BPF mode and by
  `*tables` rules (`LimitNumConnections` in `felix/rules/endpoints.go`)
  in iptables/nftables mode.
- **DSCP** marking is applied by BPF on egress to external
  destinations and HEPs.

The BPF-specific implementation lives under `felix/bpf/qos/` (Go),
`felix/bpf/conntrack/connlimit_scanner.go` (the userspace recount
loop), and `felix/bpf-gpl/qos.h` (C).

### Two maps: `cali_qos` (packet rate) and `cali_qos_conn` (connlimit)

Packet-rate and connection-limit state live in two separate BPF maps
that share the same key shape but have disjoint values. Splitting
them is what allows the userspace `ConnLimitScanner` to write
`current_count` back without clobbering the BPF dataplane's running
token-bucket state.

Shared key (`felix/bpf/qos/map.go`):

```c
struct calico_qos_key {
    __u32 ifindex;
    __u16 ingress; // 0=egress, 1=ingress
    __u16 family;  // 4=IPv4, 6=IPv6
};
```

The family dimension means v4 and v6 traffic on the same (ifindex,
direction) count against independent entries, matching iptables and
nftables semantics where connlimit and rate-limit rules live in
family-specific chains.

`cali_qos` value holds packet-rate state only:

```c
struct calico_qos_val {
    struct bpf_spin_lock lock;
    __s16 packet_rate, packet_burst;       // config
    __s16 packet_rate_tokens, padding[3];  // dynamic state
    __u64 packet_rate_last_update;         // dynamic state
};
```

`cali_qos_conn` value holds connlimit state only:

```c
struct calico_qos_conn_val {
    struct bpf_spin_lock lock;
    __u32 max_connections;  // config
    __u32 current_count;    // dynamic state
};
```

Both maps use a `BPF_F_NO_PREALLOC` hash with a BPF spinlock at
value offset 0. They are created with BTF type info via the shared
`common_map_stub.o` so the kernel can validate `BPF_F_LOCK` writes.

### Packet rate

Packet rate is enforced per-interface, per-direction, per-family.
`qos_enforce_packet_rate` in `qos.h`:

- No entry in `cali_qos` → no rate limit → accept.
- Under the spinlock: advance the token count based on elapsed time,
  cap at burst, decrement by one per accepted packet.
- No tokens → drop with `TC_ACT_SHOT`.

The per-direction `INGRESS_PACKET_RATE_CONFIGURED` /
`EGRESS_PACKET_RATE_CONFIGURED` flags (set on the AttachPoint and
propagated to BPF globals) let the program skip the map lookup
entirely when the feature isn't configured for that attach point.

### Connection count

Connection count is enforced at TCP-SYN admission time and decremented
on connection close.

- **SYN admission**: at `to-wep` for ingress and at egress
  CT-create time for outgoing connections,
  `qos_connlimit_check_and_increment` (in `qos.h`) looks up the
  `cali_qos_conn` entry for this (ifindex, direction, family).
  No entry or `max_connections <= 0` → no limit. Otherwise:
  spin-lock; if `current_count >= max_connections` → return -1
  (reject with TCP RST); else increment and return 0 (allow).
- **CT-entry stamping**: a successful admission stamps
  `CALI_CT_FLAG_CONNLIMIT_INGRESS` (or `_EGRESS`) on the CT entry.
  Rejection at the ingress check stamps `CONNLIMIT_INGRESS_REJECTED`
  instead. These flags drive the close-time decrement decision.
- **Decrement on close (fast path)**: `qos_connlimit_decrement_for_ct`
  (in `conntrack.h`) is invoked from `calico_ct_lookup` when a TCP
  close is observed. It decrements the per-(direction, family)
  counter for an entry that was counted at SYN time — gated on
  `(INGRESS && !INGRESS_REJECTED)` or `EGRESS`, idempotent via
  `CONNLIMIT_DEC`.
- **Decrement on cleanup**: when the BPF conntrack cleanup scanner
  removes an expired CT entry, the same helper fires so silent
  purges (LRU eviction, idle TCPEstablished) don't leak slots.
- **Decrement (safety net)**: a userspace `ConnLimitScanner`
  (`felix/bpf/conntrack/connlimit_scanner.go`) recounts established
  TCP CT entries once per CT scan and overwrites `current_count` in
  `cali_qos_conn` via `BPF_F_LOCK` batch updates. It skips a close
  both endpoints agreed on — either FIN bit — which the fast path has
  already accounted for, and nothing else.

**An RST releases no slot.** One RST is a single packet from either
side, so a pod holding only `CAP_NET_RAW` could forge one per
connection and admit an extra; two FINs cannot be forged by one party.
An RST-closed connection keeps its slot until its entry is purged — at
`TCPResetSeen`, or at the two-minute residual window if a packet crossed
the close — and the next recount rebases. That purge waits while Linux
conntrack still carries the flow before close, as it can for a flow that
crosses netfilter (nat-outgoing): the entry must outlive the connection,
never the reverse, or it runs uncounted. Idling past `TCPEstablished`
still purges it.

For the same reason the recount skips no RST state, and no other
signal a pod can refresh at will: anything it honours is something a
pod can hide its live connections behind.

Host-originated traffic — including from host-networked pods — is
exempt from the ingress limit: it takes the `skip_policy` path in
`tc.c` and the admission check is gated on `!policy_skipped`, so it is
neither counted nor limited. Same in iptables/nftables, where the
connlimit rule sits in `cali-tw-<iface>`, a chain host-origin traffic
never reaches.

The per-direction `INGRESS_CONN_LIMIT_CONFIGURED` /
`EGRESS_CONN_LIMIT_CONFIGURED` flags gate the BPF connlimit code
path entirely when no limit is configured for that attach point.

### DSCP

DSCP marking is configurable via the `qos.projectcalico.org/dscp`
annotation on a HEP or WEP. The value is carried in BPF globals as
`EGRESS_DSCP` and applied on egress:

- On a new egress connection, if DSCP is configured and the
  destination is outside the cluster or on a HEP (see
  `cali_rt_flags_should_set_dscp` in `felix/bpf-gpl/routes.h`), the
  BPF program sets `CALI_CT_FLAG_SET_DSCP` on the conntrack reverse
  entry (`conntrack_types.h`).
- On every subsequent egress packet on that flow, `CALI_ST_SET_DSCP`
  is raised from the CT flag and `qos_dscp_set` rewrites the IP
  header:
  - IPv4: upper six bits of the TOS byte (checksum is recomputed).
  - IPv6: upper four bits of `priority` and the top two bits of
    `flow_lbl[0]` (traffic class = DSCP + ECN).

The ECN bits are preserved in both address families. Istio's DSCP
hook (for L7 mesh identification at connection setup) uses a second
global, `ISTIO_DSCP`; see
[Istio ambient mode](#istio-ambient-mode-integration).

### Review notes for this section

- A new BPF-enforced QoS field needs a slot in either `cali_qos` or
  `cali_qos_conn`, depending on which writer owns it. Both structs
  contain a `bpf_spin_lock` at offset 0, which must stay at offset 0;
  its presence also means the map is a `BPF_MAP_TYPE_HASH` (not
  LRU/percpu/etc.). Do not relax those without a plan for concurrent
  access. **Do not add a field that needs to be written by both the
  BPF dataplane and userspace to the same value** — userspace cannot
  RMW under the lock (locks don't span syscall boundaries), and the
  cali_qos / cali_qos_conn split exists precisely to avoid that
  class of lost-update.
- Any change to packet-rate accounting must preserve the spinlock
  discipline: all reads and writes of `packet_rate_tokens` /
  `packet_rate_last_update` happen under the lock, and the drop
  decision is part of the atomic section. Dropping outside the lock
  allows overshoot.
- Any change to connlimit decrement paths must preserve the
  `CONNLIMIT_DEC` idempotence flag — both the fast path (both FINs in
  `calico_ct_lookup`) and the cleanup path (BPF conntrack cleanup
  scanner) set it before decrementing. It is what stops the same
  close being counted twice when both paths see one entry.
- `CONNLIMIT_DEC` is an idempotence latch, **not** a statement that
  the connection is gone. Do not gate the userspace recount on it,
  and do not add any other long-lived "already handled" marker that
  the recount honours. The recount is the only path that can return
  a slot, so anything it skips unconditionally is leaked for the
  life of the entry. Keep the skip conditions to state that clears
  itself or dies with the entry: the per-leg FIN/RST bits.
- Where `calico_ct_lookup` releases the latch (when it clears
  `v->rst_seen` after two minutes of continued traffic), release it
  with an atomic AND on `type_flags_word`, mirroring the claim: a
  byte-wide `ct_value_clear_flags()` would race with a concurrent
  claim on another CPU.
- When choosing between holding a slot too long and releasing one
  too early, hold. Over-counting fails closed — a pod is briefly
  refused a connection it could have had. Under-counting fails open:
  the limit stops being a limit, and if the under-count is permanent
  an unauthenticated packet defeats it outright.
- ep_mgr writes to `cali_qos` / `cali_qos_conn` must skip the
  UpdateWithFlags when the configured fields match the existing
  entry. The dataplane owns the dynamic fields between configuration
  changes; rewriting them from a userspace snapshot races with
  per-packet updates. See `writeQoSRateEntry` and `writeQoSConnEntry`.
- A change that extends DSCP marking to new paths (a new tunnel
  type, a new forwarded-packet case) must set `CALI_ST_SET_DSCP`
  based on the CT flag, _not_ on the globals alone — globals are a
  per-attach-point configuration, not a per-flow decision. The CT
  flag is what records the per-flow policy decision.

## Istio ambient mode integration

The BPF dataplane's only contribution to Istio ambient mode is
**marking the TCP SYN of a new flow between two mesh workloads
with a configurable DSCP** so ztunnel can recognise in-mesh
traffic at connection setup. Nothing else — no traffic redirection,
no ztunnel hosting, no HBONE — is in the BPF dataplane.

### BPF-side DSCP marking

On host-egress-to-WEP (`CALI_F_TO_WEP`), for TCP SYN packets only,
the main program in `tc.c` does:

1. Gate on `ISTIO_DSCP >= 0`. This per-interface global is `-1`
   by default and becomes the configured DSCP value only for
   WEPs that are mesh members, so the check vanishes for
   non-mesh interfaces.
2. Gate on `ct_result_is_syn(...)`. Established-flow packets
   skip the whole block; the fast path pays zero per-packet cost.
3. Look up the source IP in the `ALL_ISTIO_WEPS_ID` IP set
   (`RESERVED_IP_SET_BASE + 3` in `felix/bpf-gpl/policy.h`,
   shared with the Go constant `IPSetIDAllIstioWEPs` in
   `felix/rules/rule_defs.go`). This confirms the sender is also
   a mesh member.
4. On match, `qos_dscp_set(ctx, ISTIO_DSCP)` rewrites the DSCP
   bits in the IPv4 TOS / IPv6 traffic-class byte — same
   mechanics as [QoS DSCP](#dscp).

The `ALL_ISTIO_WEPS_ID` IP set is populated by Felix with every
mesh WEP in the cluster (local and remote); it lives in the
regular shared BPF IP-set map, not a dedicated one.

### Per-endpoint on/off

The feature is gated at two levels:

- **Cluster-wide:** `IstioAmbientMode` in FelixConfig (default
  `Disabled`).
- **Per-endpoint:** per-interface, the attach-point global
  `ISTIO_DSCP` is set to `-1` for WEPs that are _not_ mesh
  members and to the configured DSCP value for WEPs that are.
  Felix decides per-WEP based on the
  `istio.io/dataplane-mode` label on the WEP's namespace or the
  WEP itself (with `=none` as an opt-out); the result is tracked
  as `hasIstioDSCP` in
  `felix/dataplane/linux/bpf_ep_mgr.go` and pushed into the
  attach-point globals when the program is (re)attached.

So the DSCP marking fires only when **both** the attached WEP is
a mesh member (gate via `ISTIO_DSCP >= 0`) **and** the source is
a mesh member (IP-set lookup). Neither side by itself triggers
the rewrite.

The DSCP value is configurable via `IstioDSCPMark` (default `23`,
a convention shared with Istio ztunnel).

### Review notes

- The SYN-only path is load-bearing. A change that moves Istio
  DSCP marking onto every packet breaks the
  [fast-path discipline](./bpf-overview.md).
  If a future feature genuinely needs per-packet Istio DSCP,
  reuse `CALI_CT_FLAG_SET_DSCP` / `CALI_ST_SET_DSCP` from QoS
  rather than introducing a second per-packet rewrite.
- `ALL_ISTIO_WEPS_ID = RESERVED_IP_SET_BASE + 3` is shared
  between Go and C. A change to either side requires matching
  changes on the other.
- BPF unit test: `felix/bpf/ut/istio_test.go`.

---

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