Traffic diversion (scrubbing)
!You need a scrubbing center first
Divert only works if you already operate (or pay for) a scrubbing center — a separate
box or service that filters out attack traffic and reinjects the clean traffic over GRE or a
VRF. You can now run your own with the kapkan scrub role — a managed
scrubbing node that drops the attack in its own kernel — or point
next_hop at a third-party scrubber. If you have neither, this page is not your starting
point: use blackhole or FlowSpec instead. Pointing
next_hop at a scrubber that does not exist diverts the victim's traffic into a void.
In plain terms: instead of dropping the victim's traffic, divert sends it to a cleaning box, which filters the attack and forwards the good traffic on.
A blackhole finishes the attacker's job: it drops all of the victim's traffic, the good
with the bad. The divert action instead announces the victim's host route (the single
victim address, a /32 or /128) toward a scrubbing center — its BGP
next-hop, plus an optional divert community — so the traffic is rerouted, cleaned, and
reinjected rather than dropped. It is the natural rung between
FlowSpec (surgical drops of one vector) and blackhole
(the last resort that takes the victim offline).
scrubbing:
next_hop: "192.0.2.100" # scrubbing center BGP next-hop (v4); required to divert
next_hop6: "100::100" # required when IPv6 space is protected
community: "65000:200" # optional divert community (the next-hop does the rerouting)
# communities: ["65000:200", "65000:201"] # or a full set
local_pref: 200 # often raised so the divert route wins selection
mitigation: divert # divert every detected attack on this scope
If you also set an escalation: ladder (see below), it takes
over and this single mitigation: line is ignored.
How it works
Divert reuses the RTBH (blackhole) host-route machinery. Where a blackhole
announces the victim's /32 or /128 toward a discard next-hop (an address your routers send
blackholed traffic to and then drop — a null route) with your RTBH community, a
divert announces the same host route toward the scrubbing center's next-hop with the divert
community (and an optional LOCAL_PREF — a BGP tie-breaker that makes the divert path
preferred). A /32//128 host route is the most specific possible match, so it already wins
over any broader route covering the same address; LOCAL_PREF only breaks ties between
announcements of that same exact route, so raising it keeps the divert path winning. Your
edge then carries that victim's traffic to the scrubber; the scrubber drops the attack and
reinjects the clean remainder toward the real destination.
Reinjection of cleaned traffic — over GRE, a VRF, or a separate routing context — is the scrubber's job, outside Kapkan's BGP signaling. Kapkan's role is to steer the victim's traffic to the scrubber for the duration of the attack and steer it back afterward.
iA divert route is withdrawn like any ban
The route is withdrawn on attack end or TTL exactly like a blackhole, and the scrubbing attributes are frozen on the ban when it is created — a config reload changes only future bans, never the route a live ban already announced.
Verify before going live
Kapkan ships dry-run on by default (dry_run: true) — it logs the route it would
announce but sends nothing to your routers until you explicitly set dry_run: false. Use that
window to check your divert path before any real traffic moves:
- With dry-run on, trigger or wait for a ban and look in the logs for the line Kapkan emits,
e.g.
DRY-RUN: would announce mitigation (not sent) method=divert route="divert 203.0.113.5/32 next-hop 192.0.2.100 community 65000:200" .... The same string appears in theroutefield of the ban object. - Confirm that
routeline is exactly what you intended — the right victim prefix, the right scrubber next-hop, the right community. - Confirm the BGP session to your scrubber (and any upstream that reroutes on the community) is up.
- Only then set
dry_run: falseto start announcing for real.
The scrubbing block
| Key | Meaning |
|---|---|
next_hop | The scrubbing center's IPv4 BGP next-hop. Required whenever a ladder or method diverts. |
next_hop6 | The scrubbing center's IPv6 next-hop. Required when the deployment protects IPv6 space — there is no discard-style fallback for diversion, traffic must reach a real scrubber. |
community | Optional divert community in ASN:value form — a tag attached to the route. Whoever performs the rerouting (your upstream or the scrubbing provider) decides what it means; Kapkan just attaches it. Leave it unset if your scrubber reroutes purely on the next-hop. |
communities | Optional community list that overrides community when set. |
local_pref | Optional LOCAL_PREF attached to the divert route. It arbitrates best-path selection between same-prefix announcements (not specificity — the host route already wins that), so raising it keeps the divert path winning. Default 0 omits it. |
The divert route Kapkan builds reads as divert <prefix> next-hop <next_hop> community <community> — the string surfaced in the route field of the ban object, with
method: divert.
Managed scrubbing nodes
The scalar next_hop above diverts toward one scrubber that you operate or a provider runs
— Kapkan announces the route and the scrubber does the rest. A managed scrubbing node is
Kapkan's own scrubber: a box running the kapkan scrub role that receives the
diverted traffic, drops the attack in its in-kernel data plane, and reinjects
what is left. Kapkan announces the victim toward the node and tells the node exactly what to
drop — the same FlowSpec rules the detector generated — so the node filters the
attack vector rather than every packet to the victim.
List each node under scrubbing.nodes[]. The scalar next_hop stays valid as the one-node
degenerate case and as the catch-all target for groups no node serves (that is the migration
story: add nodes[] beside your existing next_hop, and move groups over one at a time).
scrubbing:
next_hop: "192.0.2.100" # still valid: the one-node case / catch-all target
node_selection: affinity # affinity (default) | least_loaded | ecmp
on_all_nodes_lost: withdraw # withdraw (default) | blackhole | flowspec
stale_after_seconds: 15 # a node unheard-from this long counts as lost
nodes:
- name: scrub-fra1 # must match the node's controller.name
next_hop: "192.0.2.10" # the node's IPv4 BGP next-hop (required)
next_hop6: "2001:db8::10" # required to divert IPv6 victims to this node
capacity_mbps: 10000 # shown in the console; used by least_loaded
hostgroups: [game-servers] # this node serves only these groups (empty = any)
- name: scrub-ams1
next_hop: "192.0.2.11"
| Key | Meaning |
|---|---|
nodes[].name | Node identity. Must equal the controller.name the node's agent presents when it polls, or every poll is a loud 404. |
nodes[].next_hop / next_hop6 | The node's BGP next-hop(s). next_hop is required; next_hop6 is required to divert IPv6 victims to that node. |
nodes[].capacity_mbps | The node's scrubbing capacity, shown in the console and used by least_loaded. 0 means unknown. |
nodes[].hostgroups | Restricts which hostgroups this node serves. Empty means it accepts any group. A group whose ladder diverts must have a node that serves it, or the scalar next_hop as a fallback. |
node_selection | How a node is chosen for a new ban: affinity (default — the first configured node whose hostgroups claim the victim), least_loaded, or ecmp. Only affinity is implemented today; the others log a warning at startup and fall back to it. |
on_all_nodes_lost | What to do when no managed node is reachable: withdraw (default — stop attracting the victim's traffic), blackhole, or flowspec. While at least one node survives, the victim is re-announced toward it instead. |
stale_after_seconds | How long a node may go without polling before it counts as lost. Default 15. For a silent death (power loss, a partition — nothing sends a FIN) the real detection bound is this plus the long-poll hold the node may be parked in (up to 30 s); size it against your blackhole tolerance with that sum in mind. |
The node choice is frozen, and survives node loss
The node for a ban is chosen once, at ban time and frozen on the ban like its BGP attributes — a victim's traffic never hops between scrub sites because a reload reordered the list. Selection prefers nodes that are actually polling, so a fresh ban is never pointed at a node the brain can already see is gone.
When a node stops polling for stale_after_seconds, the sweep moves its victims:
- to a surviving eligible node — re-announced make-before-break on the same host route, so the victim is never briefly unprotected. Once moved, the choice is frozen again: the old node coming back does not pull the victim home.
- through
on_all_nodes_lostonly when no eligible node survives.flowspecneeds its rules, so a divert ban under that policy generates them at ban time.
iLiveness is the poll, never the report
A node is alive because it keeps polling the brain for rules — that poll is the only liveness
signal. A node's self-report (load, drop counts, version, shown in the console)
is advisory: a compromised node token could inflate a report, but it can never keep a dead node
attracting diverted traffic, because nothing acts on the report. A node freshly added by a reload
(or after a brain restart) gets one appearance window before it can be judged lost, so a routine
rollout never trips on_all_nodes_lost.
Getting the diverted traffic to a node and the clean traffic back out — L2 insertion, GRE/IPIP tunnels with MSS clamping, the return path, and the asymmetric-routing traps — is your network's job, covered in the network integration guide.
Per-hostgroup scrubbing
A hostgroup can point at its own scrubber with a scrubbing: block —
the same shape as the per-group bgp: block. Each unset field inherits the global
scrubbing block, so different customers can divert to different scrubbing centers.
scrubbing:
next_hop: "192.0.2.100" # global default scrubber
community: "65000:200"
hostgroups:
- name: customer-a
networks: ["203.0.113.64/26"]
scrubbing:
next_hop: "192.0.2.250" # customer-A's dedicated scrubber
communities: ["65000:400"]
In an escalation ladder
divert is most powerful as a rung in an escalation ladder: alert, then
surgically drop the vector, then scrub everything, and only blackhole as a last resort.
escalation:
- { after_seconds: 0, action: none } # alert only
- { after_seconds: 30, action: flowspec } # surgical drop
- { after_seconds: 90, action: divert } # scrub
- { after_seconds: 300, action: blackhole } # last resort
Ladder severity only ever strengthens: none < flowspec < divert < blackhole.
Climbing from divert to blackhole is seamless — both announce the same host route (the
/32 or /128), so the blackhole announcement atomically replaces the divert route with no
withdraw and no gap in coverage. (Cross-family switches, like flowspec → divert, stay
make-before-break.)
If your BGP peer rejects the divert route (for example, the scrubber next-hop is
unreachable), Kapkan falls back to a blackhole by default (controlled by ban.fallback; set
it to none to disable) and marks the ban with fell_back_from: divert. Watch for that in
the API or logs — it means your scrubber path is broken and the victim is being
null-routed instead of cleaned. See troubleshooting if you hit this.
!Total groups cannot divert
A total hostgroup measures aggregate traffic and has no single victim
route to announce, so it cannot divert: an inherited divert stage degrades to blackhole, and
an explicit one is a config error — the same rule as FlowSpec.
Related
- RTBH mitigation — the blackhole method divert is built on.
- FlowSpec mitigation — the surgical method a ladder can step through first.
- Escalation ladders — step from alert to FlowSpec to divert to blackhole.
- Configuration reference · REST API