eBPF TCP SYN Fingerprinting - Setup Guide

May 9, 2026 · View on GitHub

TCP SYN fingerprinting is implemented via an XDP eBPF program that captures TCP SYN packets and stores them in a BPF LRU hash map. The proxy looks up each connection's SYN data and injects the x-tcp-p0f header with the p0f-style signature.


Architecture

TCP fingerprinting uses two separate processes:

  • huginn-ebpf-agent — loads the XDP program, attaches it to the network interface, and pins BPF maps to /sys/fs/bpf/huginn/. Runs once per node (DaemonSet in K8s, sidecar in Docker Compose). Requires elevated privileges but opens no ports.

  • huginn-proxy — opens the pinned BPF maps in read mode and injects the x-tcp-p0f header. Runs as a standard Deployment with HPA.

  huginn-ebpf-agent                      huginn-proxy
  ┌─────────────────────────┐           ┌─────────────────────────┐
  │ • Load XDP program       │           │ • Open pinned maps      │
  │ • Attach to interface   │           │   (read-only)           │
  │ • Pin maps to bpffs     │           │ • Lookup per connection  │
  │ • Wait for SIGTERM      │           │ • Inject x-tcp-p0f│
  └────────────┬────────────┘           └────────────▲───────────┘
               │                                       │
               │    /sys/fs/bpf/huginn/                │
               └──────────────┬────────────────────────┘

                    tcp_syn_map_v4 (LruHashMap)
                    syn_counter (Array)
                    syn_insert_failures (Array)

Preconditions

Kernel ≥ 5.11

Required for CAP_BPF-based loading. On kernels < 5.11, BPF memory uses RLIMIT_MEMLOCK accounting (still supported but deprecated). Check: uname -r.

One agent per node

Linux allows only one XDP program attached to a network interface at a time. If two agents run on the same node, the second replaces the first's XDP program. Deploy the agent as a DaemonSet (K8s) or with network_mode: "service:proxy" (Docker Compose).

IPv4 listen address

The XDP program captures only ETH_P_IP (IPv4) packets. Configuring the proxy to listen on an IPv6 address with tcp_enabled = true causes a hard startup failure.

This is not a limitation in practice when a load balancer or ingress sits in front: the proxy only sees IPv4 connections internally regardless of the original client protocol.

bpffs

/sys/fs/bpf must be mounted as bpffs on each node. Most modern Linux distributions (systemd-based) do this automatically. Verify:

mount | grep bpffs
# Expected: bpffs on /sys/fs/bpf type bpf (rw,nosuid,nodev,noexec,relatime)

CNI must preserve real client IP (Kubernetes)

The fingerprint correlates by (src_ip, src_port). CNIs that SNAT traffic toward pods (e.g. Flannel) break the correlation. Most production CNIs (Cilium, AWS VPC CNI, Calico BGP) do not SNAT.


Agent capabilities

The agent requires the following Linux capabilities and security settings:

Capability / SettingPurpose
CAP_BPFCreate BPF maps and load BPF programs
CAP_NET_ADMINAttach XDP program to a network interface
CAP_PERFMONAllow pointer arithmetic in BPF verifier
seccomp: unconfinedDocker's default seccomp blocks the bpf() syscall
apparmor: unconfinedUbuntu/Debian's AppArmor profile blocks bpffs directory creation

Proxy capabilities

The proxy only reads pinned BPF maps:

CapabilityPurpose
CAP_BPFOpen pinned BPF maps via BPF_OBJ_GET

No seccomp:unconfined or apparmor:unconfined needed.


Configuration

Agent environment variables

VariableExampleDescription
HUGINN_EBPF_INTERFACEeth0Network interface to attach XDP to
HUGINN_EBPF_DST_IP_V40.0.0.0IPv4 destination filter (0.0.0.0 = no filter)
HUGINN_EBPF_DST_IP_V6::IPv6 destination filter (:: = no filter); quote in YAML if needed
HUGINN_EBPF_DST_PORT7000Destination port filter (proxy listen port)
HUGINN_EBPF_PIN_PATH/sys/fs/bpf/huginnPin directory (default shown)
HUGINN_EBPF_SYN_MAP_MAX_ENTRIES8192LRU map capacity (default shown)
HUGINN_EBPF_XDP_MODEnativeXDP attach mode: native (default, driver-level) or skb (generic, for veth/loopback)

Proxy configuration (config.toml)

[fingerprint]
tcp_enabled = true   # false = no BPF maps opened, no capabilities needed
VariableExampleDescription
HUGINN_EBPF_PIN_PATH/sys/fs/bpf/huginnPin directory to read maps from (default shown)

The proxy retries opening pinned maps on startup (up to 60 seconds) to handle the case where the agent starts after the proxy.


Docker Compose

See examples/docker-compose.ebpf.yml for the full working example.

The agent shares the proxy's network namespace (network_mode: "service:proxy") so XDP on eth0 captures the SYN packets arriving at the proxy. Both containers share a bpffs Docker volume for map pinning.

services:
  ebpf-agent:
    network_mode: "service:proxy"
    cap_add: [CAP_BPF, CAP_NET_ADMIN, CAP_PERFMON]
    security_opt: [seccomp:unconfined, apparmor:unconfined]
    volumes:
      - bpffs:/sys/fs/bpf

  proxy:
    cap_add: [CAP_BPF]
    volumes:
      - bpffs:/sys/fs/bpf

volumes:
  bpffs:
    driver: local
    driver_opts:
      type: bpf
      o: ""
      device: bpffs

Kubernetes

The agent runs as a DaemonSet (one per node). The proxy runs as a Deployment with HPA. Both mount the host's bpffs via hostPath.

# Agent DaemonSet (abbreviated)
securityContext:
  capabilities:
    add: [BPF, NET_ADMIN, PERFMON]
  seccompProfile:
    type: Unconfined
volumeMounts:
  - name: bpffs
    mountPath: /sys/fs/bpf
volumes:
  - name: bpffs
    hostPath:
      path: /sys/fs/bpf
      type: DirectoryOrCreate

# Proxy Deployment (abbreviated)
securityContext:
  capabilities:
    add: [BPF]
  seccompProfile:
    type: RuntimeDefault
volumeMounts:
  - name: bpffs
    mountPath: /sys/fs/bpf
    readOnly: true
volumes:
  - name: bpffs
    hostPath:
      path: /sys/fs/bpf
      type: Directory

See DEPLOYMENT.md for the full Kubernetes section.


HTTP keep-alives

XDP captures only TCP SYN packets. The fingerprint is looked up once at TCP accept time and reused for every request on that connection. As a result, x-tcp-p0f is present on all requests of a keep-alive connection — not just the first.

A SynResult::Miss (no header injected) happens when:

  • the SYN was not captured (proxy just started, stale entry, IPv6 client), or
  • the BPF map entry was evicted before the connection was accepted (very high load).

force_new_connection = true is unrelated to fingerprint availability — it controls whether the proxy opens a new TCP connection to the backend per request, not whether the client SYN is re-captured.