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:
- Source allowlist —
dataplane.allowlist. A hit passes immediately. - Protected destinations — your
protected_whitelist, mirrored into the kernel. A hit passes immediately. - Static rules — your always-on policy, first match wins.
- Dynamic source rules — per-attacker entries the detector installed.
- Dynamic victim rules — the attack's match rules, at most eight per attack, first match wins.
- 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
WARNlog line listing them; shadowed_staticsin the reload report;- the
policy_shadowedcondition on/healthzand/api/v1/status, which persists until a reload fixes the config; - the
kapkan_dataplane_shadowed_static_rulesmetric, whose only healthy value is0; - a
WARNINGfromkapkan -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_buffis allocated. Requires driver support, and does the least work per packet. - generic — the kernel calls it after the
sk_buffis 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:
| Key | Default | What it bounds |
|---|---|---|
max_dynamic_rules | 4096 | Rules the detector may install. Each ban contributes up to 8. |
max_static_rules | 256 | Your own always-on rules. |
max_ratelimit_sources | 1048576 | Per-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.
Related
- Operating & monitoring — the counters and alerts these settings drive.
- FlowSpec — the same generated rules, announced to your routers instead.
- Configuration reference — every
dataplanekey in one table. - Escalation ladders — combining the local drop with upstream methods.