GitHub

Tuning & reference

The precise reference behind the data plane: how a packet is evaluated, the difference between the policy you write and the rules the detector generates, the one capability FlowSpec cannot express, and how the limits translate into kernel memory. If you are still bringing it up, start with Installing the data plane.

What the kernel evaluates, in order

For every packet, the program walks a fixed, non-configurable order and stops at the first verdict:

  1. Source allowlistdataplane.allowlist. A hit passes immediately.
  2. Protected destinations — your protected_whitelist, mirrored into the kernel. A hit passes immediately.
  3. Static rules — your always-on policy, first match wins.
  4. Dynamic source rules — per-attacker entries the detector installed.
  5. Dynamic victim rules — the attack's match rules, at most eight per attack, first match wins.
  6. Default: pass.

iTwo allowlists, two different axes

dataplane.allowlist is a list of sources that may always send. protected_whitelist is a list of destinations that are never banned. They are not interchangeable, and both are enforced in the kernel — keeping the destination guarantee in userspace alone would leave a window where a rule installed a moment earlier could still drop traffic to a protected host.

The default verdict is always pass. There is no default-deny anywhere in the program, no mode in which adding a rule changes what happens to unmatched traffic, and no automatic dropping of fragments or unusual protocols. A packet that cannot be parsed passes and is counted, unless you opt in with drop_malformed: true. This is a mitigation executor, not a firewall: it drops what it was told to drop and forwards everything else.

Static policy versus generated rules

Static rules are what you write. They are always on, independent of any attack, and useful for traffic you never want (drop_chargen) and for standing ceilings (cap_icmp). Names are required and must be unique, so two rules can never quietly address the same entry.

static_rules:
  - name: drop_chargen
    match: { proto: udp, src_port: 19 }
    action: drop
  - name: cap_icmp
    match: { proto: icmp }
    action: ratelimit
    profile: icmp_cap

Every name here — on a rule and on a profile — is a label you invent, not a keyword; it is what the rule is called in counters, logs and the console. The only fixed vocabulary is the enumerated fields: action is pass, drop or ratelimit; match.proto is tcp, udp, icmp or icmp6 (unset means any, and ports are rejected for icmp/icmp6); match also takes src, src_port, dst_port and payload. The configuration reference lists every field and its allowed values in one table.

Matching TLS handshakes

match.payload narrows on what the payload begins with. It has one value, tls_client_hello, which matches a TCP segment opening a TLS ClientHello — the shape a TLS handshake flood is made of, and the one message an ordinary connection sends exactly once:

ratelimit_profiles:
  - { name: handshake_cap, pps: 20 }   # each source: 20 new handshakes/s

static_rules:
  - name: cap_tls_handshakes
    match: { proto: tcp, dst_port: 443, payload: tls_client_hello }
    action: ratelimit
    profile: handshake_cap

This is the rule to reach for when the flood is completed connections that each start a handshake and go no further — the expensive part for the server, and a vector a SYN-flags rule cannot see, because these connections are established. It pairs naturally with per-source buckets: a ClientHello can only arrive on a completed TCP handshake, so the sources are real addresses rather than spoofed ones, and each gets its own budget.

Because the rule matches only the handshake, an established connection's data and ACKs are untouched — a client mid-download is not competing for the same ceiling.

!Order it above your broader port-443 rule

Static rules are first match wins. If you also keep a general ceiling on {proto: tcp, dst_port: 443}, it matches ClientHellos too — so a handshake rule written below it never fires, silently, with no error and nothing in the counters to say so. Put the narrower rule first:

static_rules:
  - name: cap_tls_handshakes        # narrower — must come first
    match: { proto: tcp, dst_port: 443, payload: tls_client_hello }
    action: ratelimit
    profile: handshake_cap
  - name: cap_https_per_source      # broader ceiling on everything else
    match: { proto: tcp, dst_port: 443 }
    action: ratelimit
    profile: https_cap

Get the order wrong and Kapkan tells you: the handshake rule is named in a WARN on every policy apply and in a WARNING from kapkan -check-config. See Rules that can never fire — the analysis understands payload, so the correct order above is silent, and only the inverted one is reported.

!What it deliberately does not do

The ClientHello is read from a fixed offset and the data plane never reassembles a stream. A ClientHello split across TCP segments does not match and is forwarded, like anything else the parser cannot decide — the filter under-matches rather than over-matching, everywhere.

It is also TCP-only, which is why proto: tcp is required rather than optional. HTTP/3 is not covered: its handshake is inside QUIC on UDP/443 and is encrypted before this filter sees a byte of it. For QUIC, a per-source ceiling on UDP/443 is the tool you have.

And the encryption boundary is real in both directions: Kapkan matches the shape of a handshake, never its contents, and nothing in the data plane reads HTTP inside an established TLS session.

There is no detector-side tls_handshake_flood vector, and that is deliberate: detection runs on sampled flow telemetry, which does not carry payload bytes. This is an operator-written rule that is always on, not something a ban turns on during an attack.

Dynamic rules are generated per attack, from the same classification that drives FlowSpec — the protocol, the reflected source port of an amplification vector, TCP SYN flags, fragmentation, and the dominant attacker sources in the traffic sample. You do not write them and cannot edit them; they appear in /api/v1/bans on the ban that owns them, and they expire with it.

The two never collide: static rules occupy their own map (step 3 above), dynamic rules occupy another (steps 4–5), and each is sized by its own limit.

Rules that can never fire

Static rules are first match wins: the datapath stops at the first rule whose match the packet satisfies. So a rule whose match set is contained by an earlier rule's is dead policy — the scan stops above it every time, and it can never take a packet.

static_rules:
  - name: drop_https_flood
    match: { proto: tcp, dst_port: 443 }
    action: drop
  - name: pass_partner_https          # never fires: drop_https_flood takes these packets first
    match: { src: 198.51.100.0/24, proto: tcp, dst_port: 443 }
    action: pass

The allowlist does the same thing from precedence 1: a hit passes the packet and stops evaluation before any static rule is looked at, so adding a prefix to dataplane.allowlist disables every static drop aimed at a source inside it.

Kapkan checks both axes on every policy apply — at startup and on every reload — and names the rules it finds, in five places:

  • a WARN log line listing them;
  • shadowed_statics in the reload report;
  • the policy_shadowed condition on /healthz and /api/v1/status, which persists until a reload fixes the config;
  • the kapkan_dataplane_shadowed_static_rules metric, whose only healthy value is 0;
  • a WARNING from kapkan -check-config, which is the one place the defect can be caught before the traffic it was meant to filter arrives.

!Reported, not rejected

An unreachable rule is a warning, never a startup error. A dead rule enforces nothing, so refusing the config would trade a defect that costs zero packets today for a mitigation daemon that will not start — on a box whose whole job is to be filtering when an attack lands. Kapkan runs the config; it just does not stop telling you.

Coverage is judged on the match alone, so a rule that merely repeats an earlier rule's verdict is reported too. payload counts as part of that match, which is what keeps the recommended handshake arrangement quiet: a rule requiring payload: tls_client_hello is strictly narrower than one without it, so it cannot cover the broad port-443 ceiling written below it. Reverse the two and the handshake rule is dead — which is exactly the case this reports. The verdict is also taken per address family, because the datapath is family-strict: a rule with no match.src covers both families and is only dead when both are taken — possibly by two different earlier rules, which the message says outright. On the allowlist axis only drop and ratelimit rules are reported, since a pass rule the allowlist has already made redundant admits the same packets either way; on the first-match-wins axis every action is reported, because there the most dangerous shape is exactly a pass — a broad drop followed by the narrow exemption somebody meant to grant, which is not being granted.

Fix it by moving the specific rule above the general one, by narrowing the general one or the allowlist entry, or by deleting the rule if it was already redundant.

Per-source rate limiting

A ratelimit action is enforced per source address, not across the victim's traffic as a whole. Each source gets its own token bucket, so a limit of N packets per second holds every individual source to N — a distributed flood is throttled per participant, rather than letting a thousand sources share (and exhaust) one aggregate ceiling.

ratelimit_profiles:
  - { name: icmp_cap, mbps: 10 }      # each source: 10 Mbps
  - { name: dns_reply, pps: 50000 }   # each source: 50k pps

icmp_cap and dns_reply are names you chose, not keywords — a profile is just a named ceiling, and a static rule invokes one through its profile: field (cap_icmp above uses icmp_cap). A profile needs at least one of pps or mbps; set both and whichever is reached first stops admitting the source's packets. A profile that no rule references is dropped on the next reload.

iThis is the one thing FlowSpec cannot express

A FlowSpec traffic-rate applies to the flow the rule matches, so a rule covering a victim rate-limits all of that victim's matching traffic collectively — and legitimate clients compete with attackers for the same allowance. Per-source buckets are not a faster version of something the announcer already did; they are something it structurally could not do at all.

max_ratelimit_sources bounds how many source buckets exist at once. It is a least-recently-used table: past the limit, the least active sources are evicted, which under a very wide flood means some sources are briefly re-admitted before being throttled again.

Attach modes

There are two places in the receive path the kernel can run the program:

  • native — the driver calls it as packets come off the NIC, before an sk_buff is allocated. Requires driver support, and does the least work per packet.
  • generic — the kernel calls it after the sk_buff is allocated. Works on any interface, including virtio, and does more work per packet to get there.

xdp_mode: auto (the default) uses native where the driver supports it and generic where it does not. native refuses to start rather than fall back — what you want when you have sized for the driver hook and would rather find out at boot. generic forces the second mode; it is mainly for virtio and test environments.

!Under auto, the fallback is silent

A driver change, kernel upgrade or new NIC can move you from native to generic with no error, and generic does far less per core. Whichever mode each interface actually got is reported by kapkan dataplane status and exported as a metric — if you sized for native, alert on it. See the alert in Operating.

Rule expiry and shutdown

Every generated rule carries its own expiry timestamp, and the kernel program treats an expired rule as absent. This is load-bearing: if the Kapkan process dies, is killed or hangs, the rules it installed still stop applying on schedule — a dead control plane cannot leave a victim's legitimate traffic dropped indefinitely.

On a clean shutdown, on_exit decides what remains. The default, keep, leaves the program attached so static policy keeps enforcing across a restart or upgrade while dynamic rules age out on their own. detach removes the program entirely, which passes all traffic.

Limits and memory

limits sizes the kernel maps, which are allocated once, when the program attaches:

KeyDefaultWhat it bounds
max_dynamic_rules4096Rules the detector may install. Each ban contributes up to 8.
max_static_rules256Your own always-on rules.
max_ratelimit_sources1048576Per-source token buckets.

max_dynamic_rules must be at least ban.max_active_bans × 8; Kapkan refuses a smaller configuration, because the alternative is installs beginning to fail in the middle of an attack and quietly degrading to a blackhole. The defaults sit exactly on that boundary at 512 active bans.

Because the maps are allocated up front, the memory is reserved whether or not an attack is under way, and the per-source bucket table dominates the total. Map memory is charged to the service's memory cgroup (kernel 5.11+), so if you set MemoryMax= on the unit, size it with the maps in mind.