Sockets, Filtering, and XDP

Three concepts in the six-part treatment: the socket layer, netfilter, and XDP and tc-BPF.

A packet passing through Linux can be inspected, modified, redirected, or dropped at half a dozen distinct places, each with a different cost, a different amount of context available, and a different set of things it is allowed to do. Knowing which hook is right for a job is most of what "networking expertise" means in practice.


Concept 1: The Socket Layer

1. What problem it solves

A socket is the boundary between a protocol's asynchronous, interrupt-driven world and a program's synchronous one. Packets arrive at unpredictable times in softirq context; a program calls recv() whenever it feels like it. Something must buffer between them, apply flow control, and wake the reader.

2. Where it exists in the kernel

rg -n "struct sock \{" -A 60 include/net/sock.h | head -70
rg -n "struct proto \{" -A 40 include/net/sock.h | head -45
rg -n "SYSCALL_DEFINE3\(recvmsg|__sys_recvmsg" net/socket.c | head
rg -n "tcp_recvmsg\b|tcp_sendmsg\b" net/ipv4/tcp.c | head

3. The layering

   struct socket        the VFS-facing object; has a struct file
     └── struct sock    THE protocol-independent state
           ├── sk_receive_queue      skbs waiting for the reader
           ├── sk_write_queue        skbs waiting to be sent / acked
           ├── sk_rcvbuf, sk_sndbuf  the LIMITS
           ├── sk_wmem_alloc         bytes currently accounted
           ├── sk_prot ──▶ struct proto   tcp_prot / udp_prot / ...
           │                 recvmsg, sendmsg, connect, close, hash...
           └── sk_data_ready(), sk_write_space()   ← THE WAKEUPS
                 └── struct tcp_sock  (embeds struct sock; TCP's state)

The pattern is the same container_of inheritance as everywhere else: struct tcp_sock embeds struct inet_connection_sock embeds struct inet_sock embeds struct sock.

The receive queue and its limit are where most real socket problems live:

   softirq: tcp_v4_rcv()
     ├── is there room?  sk_rcvbuf vs sk_rmem_alloc
     │     NO  -> DROP. TCP will not ACK it, and the sender retransmits.
     │            This is FLOW CONTROL, and it is correct behaviour --
     │            but it looks like packet loss in every graph.
     └── YES -> queue it, then sk_data_ready() -> wake the reader
ss -tinm | head -20                       # per-socket: rcv/snd buffers, cwnd, rtt
cat /proc/sys/net/core/rmem_max /proc/sys/net/core/wmem_max
cat /proc/sys/net/ipv4/tcp_rmem /proc/sys/net/ipv4/tcp_wmem
grep -E 'TCPBacklogDrop|ListenOverflows|ListenDrops|TCPRcvQDrop' /proc/net/netstat /proc/net/snmp

Tip: ListenOverflows and ListenDrops in /proc/net/netstat are the counters for "the accept queue was full and a connection was dropped" — a server that is not calling accept() fast enough. Users experience it as intermittent connection failures under load; the counter names it exactly. ss -lnt shows the current and maximum accept-queue depth in its Recv-Q/Send-Q columns for listening sockets.

4. Experiment

CLAIM. Socket buffer limits produce drops that look like network loss but are entirely local.

METHOD.

# A server that accepts and then does NOT read:
python3 - <<'EOF' &
import socket, time
s = socket.socket(); s.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 8192)
s.bind(("127.0.0.1", 9999)); s.listen(1)
c, _ = s.accept()
print("accepted; not reading"); time.sleep(60)
EOF
sleep 1

# A client that writes hard:
python3 -c "
import socket
c = socket.create_connection(('127.0.0.1', 9999))
n = 0
try:
    while n < 100*1024*1024:
        n += c.send(b'x' * 65536)
except Exception as e: print('blocked/err at', n, e)
print('sent', n)
" &
sleep 3
ss -tinm '( dport = :9999 or sport = :9999 )' | head -20
grep -E 'TCPRcvQDrop|TCPBacklogDrop' /proc/net/netstat
kill %1 %2 2>/dev/null

PREDICT FIRST: the receiver never reads. How many bytes can the sender push before it blocks? Predict it from SO_RCVBUF — and then explain why the real number is larger.

Then the accept queue:

python3 -c "
import socket, time
s = socket.socket(); s.bind(('127.0.0.1', 9998)); s.listen(1)  # backlog=1
time.sleep(30)" &
sleep 1
for i in $(seq 50); do (python3 -c "
import socket
try: socket.create_connection(('127.0.0.1', 9998), timeout=1)
except Exception: pass" &) ; done
sleep 3
ss -lnt sport = :9998
grep -E 'ListenOverflows|ListenDrops' /proc/net/netstat
kill %1 2>/dev/null

5. Failure mode

MistakeSymptom
Diagnosing socket-buffer drops as network lossWeeks spent on the network; the counter was local all along
A listen() backlog that is too smallListenOverflows; intermittent connection failures under load
Not reading fast enoughThe window closes, the sender stalls, and it looks like the network is slow
Setting SO_RCVBUF explicitlyIt disables auto-tuning, and the fixed value is usually worse
Sleeping in sk_data_readyIt runs in softirq context
Assuming send() returning means transmittedIt means copied into the socket's write queue

Concept 2: Netfilter

1. What problem it solves

Firewalling, NAT, and connection tracking need to see packets at well-defined points in the routing path — and crucially, they need to see them at different points depending on whether the packet is inbound for this host, outbound from it, or being forwarded through it.

Netfilter is five hooks placed at exactly those points, plus the infrastructure for registering handlers on them.

2. Where it exists in the kernel

ls net/netfilter/
rg -n "enum nf_inet_hooks" -A 10 include/uapi/linux/netfilter.h
rg -n "NF_HOOK\(" net/ipv4/ip_input.c net/ipv4/ip_output.c | head
nft list ruleset 2>/dev/null | head -20
iptables-save 2>/dev/null | head -20

3. The five hooks

                          ┌──────────────────┐
                          │  LOCAL PROCESS   │
                          └───▲──────────┬───┘
                     LOCAL_IN │          │ LOCAL_OUT
                              │          ▼
   wire ──▶ PRE_ROUTING ──▶ [routing] ──▶ POST_ROUTING ──▶ wire
                              │          ▲
                              │ FORWARD  │
                              └──────────┘

   PRE_ROUTING   before the routing decision -- DNAT happens here
   LOCAL_IN      destined for this host
   FORWARD       passing through
   LOCAL_OUT     generated by this host
   POST_ROUTING  just before transmit -- SNAT/MASQUERADE happens here

Connection tracking (nf_conntrack) sits on these hooks and gives the stack a notion of a connection rather than isolated packets — which is what makes stateful firewalling and NAT possible, and which costs a hash-table entry per connection.

sudo conntrack -L 2>/dev/null | head
cat /proc/sys/net/netfilter/nf_conntrack_max /proc/sys/net/netfilter/nf_conntrack_count
grep -E 'conntrack' /proc/net/stat/nf_conntrack 2>/dev/null | head -3

Warning: nf_conntrack: table full, dropping packet is one of the most common production networking failures and it is invisible unless you look. The table is finite, entries live for a timeout after the connection ends, and a machine under a connection flood fills it and starts dropping everything. Check nf_conntrack_count against nf_conntrack_max before you believe any other diagnosis.

nftables replaced iptables. iptables still works — through a compatibility layer over the same infrastructure — but new work goes into nft, and the underlying model (one unified table, a bytecode VM, sets and maps as first-class objects) is genuinely better.

4. Experiment

CLAIM. The hooks fire in a defined order and you can watch a packet traverse them.

METHOD.

# Trace a packet through the hooks with nftables' built-in tracing:
sudo nft add table inet t 2>/dev/null
sudo nft add chain inet t trace_chain '{ type filter hook prerouting priority -300; }'
sudo nft add rule inet t trace_chain ip daddr 1.1.1.1 meta nftrace set 1
sudo nft monitor trace &
ping -c 2 1.1.1.1 >/dev/null 2>&1
sleep 2; kill %1; sudo nft delete table inet t

# Or with bpftrace, at the source:
sudo bpftrace -e '
  kprobe:nf_hook_slow { @hooks = count(); }
  tracepoint:skb:kfree_skb { @drops[args.reason] = count(); }' &
ping -c 5 1.1.1.1 >/dev/null 2>&1
sudo nft add table inet blk; sudo nft add chain inet blk c '{ type filter hook output priority 0; }'
sudo nft add rule inet blk c ip daddr 1.1.1.1 drop
ping -c 5 -W 1 1.1.1.1 >/dev/null 2>&1
sudo nft delete table inet blk; kill %1

PREDICT FIRST: when a packet is dropped by a netfilter rule, what SKB_DROP_REASON_* appears? And is it dropped at LOCAL_OUT or POST_ROUTING for an outbound ping?

5. Failure mode

MistakeSymptom
nf_conntrack table fullEverything drops, and nothing in the firewall rules explains it
Rules at PRE_ROUTING expecting a post-NAT addressThe translation has not happened yet
A rule at the wrong hook for NATDNAT belongs at PRE_ROUTING, SNAT at POST_ROUTING. Not interchangeable.
Long linear rule chainsEvery packet walks them. Use sets and maps in nft.
Mixing iptables and nft rulesThey share infrastructure but the interaction is confusing
Conntrack on a high-throughput forwarding pathAn entry and a lookup per connection; sometimes the right answer is to bypass it

Concept 3: XDP and tc-BPF

1. What problem it solves

Netfilter and the socket layer are downstream of sk_buff allocation, protocol parsing, and routing. For a DDoS scrubber that wants to drop 20 million packets per second, all of that is wasted work before the decision to drop.

XDP runs a BPF program in the driver, before an sk_buff exists at all — on the raw DMA buffer, immediately after the NIC wrote it.

2. Where it exists in the kernel

rg -n "enum xdp_action" -A 10 include/uapi/linux/bpf.h
rg -n "struct xdp_md \{" -A 12 include/uapi/linux/bpf.h
rg -n "bpf_prog_run_xdp|xdp_do_redirect" net/core/filter.c include/linux/filter.h | head
ls samples/bpf/ 2>/dev/null | grep -i xdp | head
ls tools/testing/selftests/bpf/progs/ 2>/dev/null | grep -i xdp | head
$EDITOR Documentation/networking/af_xdp.rst 2>/dev/null || ls Documentation/networking/ | grep -i xdp

3. Where every hook sits

   wire
     │
     ▼
   ┌──────────────────────────────────────────────────────────────┐
   │ XDP           in the DRIVER, on the raw buffer.              │
   │               NO sk_buff yet. Fastest possible. Most limited. │
   │               Returns: XDP_PASS / DROP / TX / REDIRECT / ABORTED│
   └──────────────────────────────────────────────────────────────┘
     │ XDP_PASS
     ▼
   build an sk_buff  ◀── the allocation XDP avoided
     │
   ┌──────────────────────────────────────────────────────────────┐
   │ tc INGRESS (clsact + BPF)  the skb exists; full helpers,      │
   │                            metadata, and skb rewriting        │
   └──────────────────────────────────────────────────────────────┘
     │
   ┌──────────────────────────────────────────────────────────────┐
   │ NETFILTER PRE_ROUTING → routing → LOCAL_IN                    │
   └──────────────────────────────────────────────────────────────┘
     │
   ┌──────────────────────────────────────────────────────────────┐
   │ SOCKET: cgroup/skb BPF, sockmap, socket filters (BPF/classic) │
   └──────────────────────────────────────────────────────────────┘
     │
   the application
HookSeesCanCost
XDPThe raw bufferDrop, transmit back out, redirect to another interface or to AF_XDP, modify headers within limitsLowest. No allocation.
tc ingress/egress BPFA full sk_buffEverything XDP can, plus arbitrary rewriting and full helper accessLow, after the skb cost
netfilterAn skb, post-routing-decisionStateful filtering, NAT, conntrackModerate; conntrack adds more
socket BPF / cgroupPer-socket, per-cgroupPer-application policyLate, but with the most context

The three XDP modes, and knowing which you are in matters enormously for performance:

ModeWhereNote
NativeIn the driver, on the raw bufferThe real thing. Requires driver support.
Generic (xdpgeneric)After the skb is builtWorks on any driver. Slower than not using XDP at all — it is for development only.
OffloadedOn the NIC's own processorVery few NICs
ip -d link show dev eth0 | grep -i xdp     # shows which mode is attached
ethtool -i eth0 | head -3                  # the driver, which determines native support

Warning: If you benchmark XDP and it is slower than the normal path, you are almost certainly in generic mode. virtio_net and veth do support native XDP; many other drivers do not. ip -d link show tells you: look for xdp versus xdpgeneric in the output.

4. Experiment

CLAIM. XDP drops packets before the stack does any work, and the difference is visible in /proc/net/softnet_stat and in CPU usage.

METHOD. Lab 13 does this properly. The shape:

# Baseline: flood, and measure receive processing.
sudo bpftrace -e 'tracepoint:net:netif_receive_skb { @ = count(); }' &
ping -f -c 20000 <peer> >/dev/null 2>&1
kill %1

# Then attach an XDP program that drops the same traffic, and repeat.
# @ should be ZERO -- netif_receive_skb is never reached.

PREDICT FIRST: with an XDP program returning XDP_DROP, what does netif_receive_skb count? What do the interface's rx_packets statistics show — and why are those two answers different?

5. Failure mode

MistakeSymptom
Benchmarking in generic modeXDP is slower than no XDP, and the conclusion is backwards
Returning XDP_TX without adjusting headersMalformed frames on the wire
Reading past xdp_md->data_endThe verifier rejects it — which is the safety net working
Assuming XDP sees all trafficIt is per-interface, and it is ingress only
Using XDP for something needing conntrackXDP has no connection state; that is what tc-BPF and netfilter are for
Dropping in XDP and wondering why counters disagreeInterface counters increment before XDP; stack counters do not
A large XDP programThe verifier has complexity limits, and they are the constraint you will hit

Choosing a Hook

You want toUse
Drop a flood at line rateXDP (native mode)
Load-balance packets across backendsXDP with XDP_TX/XDP_REDIRECT
Deliver packets straight to user space, bypassing the stackAF_XDP
Rewrite or encapsulate with full skb contexttc-BPF (clsact)
Stateful firewalling or NATnetfilter / nftables
Per-container or per-cgroup policycgroup BPF
Per-application filteringsocket filters, or sockmap
Shape or prioritize outbound traffica qdisc (the previous chapter)
Understand where packets are goingkfree_skb drop reasons, first

Validation / Self-check

  1. What is struct sock and how does struct tcp_sock relate to it?
  2. What happens when a packet arrives and sk_rcvbuf is exhausted? Why does that look like network loss?
  3. Which counters name accept-queue overflow, and what application bug do they indicate?
  4. Why is setting SO_RCVBUF explicitly usually a mistake?
  5. Name the five netfilter hooks and say where DNAT and SNAT each belong.
  6. What does conntrack add, what does it cost, and what is its characteristic production failure?
  7. Where does XDP run relative to sk_buff allocation? What does that buy and what does it cost?
  8. Name the five XDP return actions.
  9. Name the three XDP modes and say which one makes benchmarks lie.
  10. Compare XDP, tc-BPF, netfilter, and socket BPF on what each can see and do.
  11. You need stateful NAT at 10 Mpps. Which hook, and what is the tension?
  12. Packets are disappearing between the NIC and the application. Give your first three measurements, in order.

Next: Lab 13 — An XDP Packet Counter — run code before the kernel has built a packet.