In-kernel data plane
Every other mitigation method Kapkan has is a request. RTBH and FlowSpec ask your routers to drop traffic; diversion asks them to send it somewhere else. All three depend on a peer that is willing and able to act on the announcement.
The data plane drops packets itself. Kapkan loads a small XDP program into the Linux kernel of the machine it runs on, and the rules the detector already builds for FlowSpec are written into kernel maps instead of being announced. Nothing has to propagate, and no peer has to accept anything.
This page is the overview. It answers three questions in order — is this for me, does it work, and where do I go next — and the rest of the section is the how:
- Installing the data plane — the kernel and capability requirements, the config block, and how to bring it up safely in dry-run.
- Operating & monitoring — reading its status, the counters and metrics to watch, the one alert you must set, and what to do when something is wrong.
- Tuning & reference — the packet-evaluation order, static policy, per-source rate limiting, attach modes, and the map-sizing limits.
Is it for you? The one question that decides it
The data plane can only drop packets that reach the machine Kapkan runs on. That single fact decides whether the feature applies to your deployment, so answer it before anything else.
It helps when Kapkan runs in the traffic path:
- on a Linux border router or a bump-in-the-wire box the traffic already crosses;
- on the host being attacked, protecting itself;
- on a dedicated scrubbing node that diversion steers traffic to.
It does nothing when Kapkan runs off-path — the classic deployment, where a Kapkan VM receives NetFlow or sFlow from your routers and speaks BGP back to them. No attack traffic flows through that VM, so there is nothing there for it to drop. In that topology, keep using RTBH and FlowSpec, and reach for the data plane only on a scrubbing node.
!It cannot un-saturate an upstream link
An XDP filter runs on packets that have already arrived on this machine's NIC. If a flood has already filled the pipe upstream of the box, the link is congested before the filter sees a packet, and dropping locally changes nothing. That is what the heavier, upstream steps are for. In an escalation ladder the data plane is the most precise response — not a replacement for the ones that move the drop upstream.
Does it work? Measured, not asserted
Every release replays eighteen attack captures end to end — synthetic telemetry into the real detector and classifier, the rules it generates compiled into real kernel maps, then the captured frames replayed through the program. Each capture runs a stream of legitimate traffic alongside the attack, because a block rate measured without one means nothing.
| Measured over 18 captures | Result |
|---|---|
| Attack traffic dropped | 100% on 17 of 18; 98.5% on the per-source rate-limit capture, where each source is allowed its first frame before its bucket empties |
| Legitimate traffic dropped | zero, in all 18 |
| Allowlisted traffic dropped | zero, in all 18 |
The captures cover UDP and SYN floods over IPv4 and IPv6; DNS, NTP, CLDAP, SSDP, memcached and
chargen amplification; ICMP and fragment floods; a TCP ACK flood; a multi-vector mix; a
carpet-bombed /24; a source flood; VLAN-tagged frames; and IPv6 with extension headers. The full
suite also runs on real 5.15, 6.1, 6.6 and 6.12 kernels on every change.
iA block rate is not a throughput number
These numbers say what fraction of an attack the rules catch. They say nothing about how many packets a given box can absorb — that depends on your NIC, driver and CPU, and on whether the program attached in native or generic mode. Size a deployment on your own hardware; do not read capacity off this table.
How it fits the rest of Kapkan
The data plane is a mitigation method, alongside RTBH, FlowSpec and diversion, and it inherits everything the others do. Detection is unchanged. The safety model — dry-run, whitelists, TTLs and blast-radius caps — applies exactly as it does to a BGP announcement, because the data plane plugs in at the same point. What changes is only the last step: instead of an announcement leaving the box, rules are written into the kernel.
Severity runs none < dataplane < flowspec < divert < blackhole. On an
escalation ladder a dataplane rung is the gentlest real action — drop locally
first, and escalate to the router-based methods only if that was not enough.
Source blocks: from your own stack
Everything above is Kapkan deciding. There is one more way rules get into the kernel: something you run decides, and Kapkan enforces. An nginx in front of the victim, a log exporter, or an operator at 3am can hand over a source to drop, scoped to one victim, with a mandatory TTL:
curl -fsS -X POST localhost:8080/api/v1/dataplane/sources \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"victim":"203.0.113.10","source":"198.51.100.7","ttl_seconds":600,"reason":"login flood"}'
This is HTTP awareness without parsing HTTP. Kapkan never sees the requests; the thing that already terminates them makes the call, and the drop happens at the cheapest layer that can express it. Detection is not involved — no threshold has to trip, and nothing is classified.
The pair lives in the kernel with its own expiry, so it lapses on schedule even if Kapkan
dies. POST /api/v1/dataplane/sources/unblock is the immediate undo. Full request shape,
response and every refusal: the source-block channel.
The limits, because they are shared. Each blocked source claims one kernel policy slot from
the same pool bans draw on, and holds at most 8 victims in it. Distinct sources are capped at
what is left after every possible ban — host and carpet — could claim its own slot, so a burst of
blocks can never starve a ban into its blackhole fallback. Past that, blocks are refused with
409 while bans keep working. Raise dataplane.limits.max_dynamic_rules if you intend to lean on
this channel; watch kapkan_mitigate_source_blocks against that limit.
!The 8-victim cap is per source, across all tenants
One source's pairs share one kernel policy block, and that block is keyed by the source alone — it has no notion of which tenant each victim belongs to. So if the same attacker hits victims in several tenants, those victims compete for the same 8 slots, and the ninth is refused no matter whose it is. This is structural to the one-policy-per-source layout, not a limit that can be raised. Where that matters, keep source blocks to the tenant that reported the traffic, or fall back to a static rule.
Two refusals surprise people, and both exist because the alternative is silence: a source in
dataplane.allowlist and a victim in protected_whitelist are errors, not accepted no-ops.
The datapath passes both before any rule is evaluated, so such a block would install and then
never match a packet. A source inside your own networks is refused too — an internal host is a
ban, not a source block.
The reference exporter: kapkan nginx-exporter
You do not have to write the caller yourself. The binary ships one — a supported component, not an example — that tails an nginx access log, measures each source's request rate (and 4xx/5xx share) against a victim per window, and posts the verdicts to the channel above:
KAPKAN_API_TOKEN=... kapkan nginx-exporter \
-log /var/log/nginx/kapkan.json.log \
-api http://127.0.0.1:8080 \
-rps 50 -min-requests 100 -window 10s -ttl 5m
It needs one thing from nginx — a JSON access log with the source, the destination and the status:
log_format kapkan escape=json
'{"src":"$remote_addr","dst":"$server_addr","status":"$status"}';
access_log /var/log/nginx/kapkan.json.log kapkan;
Extra fields are fine (it ignores them), dst is the victim each block is scoped to
(-victim overrides it when $server_addr is not the protected address), and -error-ratio 0.9 narrows verdicts to sources whose traffic is mostly rejects — leave it at 0 to catch
well-formed floods too. -observe runs the whole loop but posts nothing, logging what it
would block: the trial mode for a brain that is already live (the brain's own dry_run
covers the deployment-wide case).
Some grounding for what it is: a fixed threshold an operator wrote, applied where requests are
visible — not a detector (no baselines), not a WAF (it never reads request content — source,
destination, status, nothing else). Every guarantee stays brain-side and every verdict is
audited there like any operator's; the exporter is an ordinary API caller and can bypass
nothing. It never reads log history on start, follows logrotate (create-new rotations are
drained to the last line the old file held at the switch; what can still be missed is what
nginx writes to the rotated file after the switch, until nginx's own reopen — typically
milliseconds; copytruncate additionally has an inherent detection window of one 200ms poll,
so prefer create-new), and a persistently hot source is refreshed before its TTL lapses —
which is why -ttl must be at least twice -window, enforced at startup.
!src must be the socket's address — and the socket must be the client's
The block aims at whatever the log's src says, so src must stay $remote_addr — the
address of the TCP connection nginx accepted. Never substitute a header-derived address
(X-Forwarded-For, a realip value taken from an untrusted header): headers are
attacker-written, and an attacker who controls src chooses what gets blocked. And if this
nginx sits behind a load balancer or CDN, $remote_addr is that balancer — a "block"
would cut the front door off for everyone. Run the exporter only on the box where real client
connections terminate.
The token it needs is an operator token — a full operator credential for its tenant, not an exporter-shaped one: whoever holds it can also ban and unban inside that tenant, so scope it to the tenant whose victims this box serves (see authentication), keep it in a root-owned env file, and treat the box as holding that power. For a brain that is not on the same machine, put the API behind TLS — the token otherwise crosses the network in cleartext, and the exporter says so loudly at startup.
Where to go next
New to it? Read Installing the data plane and bring it up in dry-run, then Operating & monitoring to learn what to watch. Reach for Tuning & reference when you want static policy, per-source rate limiting, or to understand exactly how a packet is evaluated.