GitHub

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:

  1. 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 the route field of the ban object.
  2. Confirm that route line is exactly what you intended — the right victim prefix, the right scrubber next-hop, the right community.
  3. Confirm the BGP session to your scrubber (and any upstream that reroutes on the community) is up.
  4. Only then set dry_run: false to start announcing for real.

The scrubbing block

KeyMeaning
next_hopThe scrubbing center's IPv4 BGP next-hop. Required whenever a ladder or method diverts.
next_hop6The 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.
communityOptional 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.
communitiesOptional community list that overrides community when set.
local_prefOptional 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"
KeyMeaning
nodes[].nameNode 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_hop6The node's BGP next-hop(s). next_hop is required; next_hop6 is required to divert IPv6 victims to that node.
nodes[].capacity_mbpsThe node's scrubbing capacity, shown in the console and used by least_loaded. 0 means unknown.
nodes[].hostgroupsRestricts 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_selectionHow 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_lostWhat 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_secondsHow 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_lost only when no eligible node survives. flowspec needs 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.