GitHub

Configuration reference

iPrefer a guided form?

The config builder assembles a valid config in your browser, with inline validation and sensible dry-run-safe defaults — nothing you enter leaves the page.

Kapkan is configured by a single YAML file passed with -config. The repository ships two starting points: configs/dev.yaml for local development (test prefixes, loopback bind, dry_run: true) and engine/deploy/config.example.yaml for production, which carries authoritative inline comments and defaults. Copy the production example to /etc/kapkan/config.yaml and adapt it to your network.

iNew to BGP / RTBH / FlowSpec?

This page assumes you know what these terms mean. If you don't, read How it works first, or keep the glossary open in another tab — every acronym below is defined there. In short: BGP (Border Gateway Protocol) is how routers exchange routes, and Kapkan acts as a BGP speaker that tells your routers which routes to install in order to mitigate an attack.

The file is reloadable at runtime. Send SIGHUP (systemctl reload kapkan) or POST /api/v1/config/reload and Kapkan re-reads the file and applies the new settings without dropping flow ingestion. Almost everything reloads live, including dry_run, thresholds, networks, hostgroups, baselines, notification settings and sampling.default_rate. Seven things are fixed at startup — a reload that changes any of them is rejected (not silently ignored), so you must restart the daemon to change them:

  • the listen addresses;
  • BGP identity (bgp.local_asn, bgp.router_id);
  • api.listen;
  • the samples settings;
  • storage settings;
  • geoip settings;
  • the data-plane attachment and map sizing (dataplane.enabled, interfaces, xdp_mode, pin_path, on_exit and everything under limits) — none of these can change under a loaded XDP program. The static policy in the same block — allowlist, static_rules, ratelimit_profiles — reloads live.

!Dry-run defaults to on

dry_run defaults to true, and an absent dry_run key is treated as true. Keep it that way until you have validated detection against live telemetry. See Going live.

Validating the configuration

Before you reload or go live, check a file with the engine's own validator:

kapkan -check-config /etc/kapkan/config.yaml

It runs the exact same parse and validation the daemon does — including the cross-field rules a form cannot express (no overlapping networks, hostgroup prefixes contained by a protected network, monotonic escalation ladders) — then prints the resolved configuration (mode, protected networks, and the effective mitigation per hostgroup) and exits 0. On any problem it prints the exact error and exits 1, so it drops cleanly into CI or a pre-deploy check.

kapkan -dump-schema prints the configuration's JSON schema to stdout; the config builder consumes that schema to validate fields in your browser as you type. Both flags, and every other one the binary accepts, are listed in the CLI reference.

Secrets and environment variables

Secrets are never written in the config file. Each one names an environment variable, and Kapkan reads the value from the process environment at load time:

  • notify.telegram.token_env — the Telegram bot token.
  • notify.email.username_env / notify.email.password_env — SMTP credentials.
  • storage.clickhouse.username_env / password_env — ClickHouse credentials.
  • api.token_env (or each api.tokens[].token_env) — the REST API / dashboard bearer token(s).

With the systemd unit, these live in an EnvironmentFile such as /etc/kapkan/kapkan.env with 0600 permissions:

KAPKAN_TG_TOKEN=123456:abc...
KAPKAN_API_TOKEN=a-long-random-string

Top-level keys

Every top-level key, its meaning, and its default. For the larger subsystems, a one-line summary points to the dedicated page.

iMinimum config to run

Most keys below are optional. To start the daemon at all you only need: at least one listen address, sampling.default_rate (>= 1), at least one protected prefix in networks, and thresholds.pps / .mbps / .flows_per_sec (all > 0). The default api.listen is 127.0.0.1:8080, so you can leave it out. To actually announce blackholes (i.e. once you turn off dry_run) you also need a bgp block. Everything else — carpet, baseline, flowspec.source_anchored, flow_sources, geoip, escalation, dataplane — is an optional refinement.

KeyMeaning
dry_runWhen true (the default, including when absent), blackholes are logged and tracked but never announced.
listen.sflow / listen.netflowUDP listen addresses. NetFlow v5/v9 and IPFIX share the netflow socket. At least one listener is required. See How it works.
sampling.default_rateSampling rate used when an exporter does not report its own. Must be >= 1.
sampling.boundaryOptional per-exporter interface-boundary counting that deduplicates a flow seen at multiple sampling vantage points (redundant exporters, ingress+egress sampling, transit links). Each entry gives an exporter's external_ifindexes (and optional egress_sampling); a flow is then counted only when it crosses an external interface. Exporters without an entry count every sample. See Sampling correction below.
sampling.boundary_debugWhen true, exports kapkan_engine_boundary_debug_bytes_total (bytes per exporter and interface) to help identify external interfaces. Not cardinality-bounded — enable briefly, then disable.
flow_sourcesOptional allowlist of trusted exporter source addresses. Telemetry arrives over unauthenticated UDP, so when set only listed sources are labeled by exporter on metrics (everything else is bucketed under other), bounding metric label cardinality. Empty applies an automatic cap instead. Does not affect detection.
networksProtected prefixes (IP ranges in CIDR form, e.g. 203.0.113.0/24). Detection applies only to destinations inside these, and they must not overlap.
protected_whitelistAddresses that are never banned, regardless of traffic. See Safety model.
thresholds.pps / .mbps / .flows_per_secPer-destination thresholds after sampling correction. All must be > 0. See Detection.
thresholds.tcp_pps / udp_pps / icmp_pps / tcp_syn_pps / frag_pps (and each _mbps variant)Optional per-protocol limits. A value of 0 or an absent key disables that limit. Any crossed threshold triggers (OR semantics).
thresholds_outgoingOptional. Detects attacks originated by protected hosts (compromised machines). Same keys as thresholds; at least one must be set. Absent, outgoing traffic is not counted. See Detection.
hostgroups[]Optional named prefix groups with their own thresholds and mitigation policy, including per_host or total calculation. Each may carry a tenant label. See Hostgroups.
tenantOptional tenant label for the implicit global/fallback group. See Multi-tenancy.
samples.enabled / buffer_flows / flows_per_attackContinuous traffic buffer for attack samples (defaults: true / 65536 / 20; buffer_flows max 1048576). Sizing changes require a restart.
baselineOptional continuous learned per-host thresholds (EWMA — exponentially weighted moving average), per-hostgroup overridable. See Baselines.
carpetOptional carpet-bombing (subnet-spread: volume spread thinly across many addresses in a prefix) detection: raises a prefix-scoped attack when traffic aggregated over a /24 (aggregation_prefix_v4/v6) crosses carpet.thresholds across at least min_hosts distinct hosts — catching floods that stay under every per-host threshold. Alert-only unless carpet.mitigation (flowspec, dataplane or blackhole) is set; dataplane drops the vector in this box's kernel and requires a dataplane block with enabled: true. Every method refuses a prefix containing a whitelisted address, counts the prefix's full address span toward ban.max_banned_fraction, and is capped by carpet.max_active_prefix_bans.
ban.ttl_secondsHow long a ban lasts without renewed detection — a sustained attack keeps refreshing it, so the route auto-withdraws this long after detection stops. No permanent bans.
ban.unban_hysteresis_secondsTraffic must stay below threshold this long before a ban is withdrawn, preventing flapping.
ban.max_active_bansHard cap on simultaneous bans. New bans past the cap are refused.
ban.fallbackWhat to do when a flowspec/divert announce is rejected by the peer: blackhole (default) degrades to an RTBH route (Remotely-Triggered Blackhole — a route that null-routes all traffic to the victim IP) so the victim is still mitigated; none rejects the ban. Blackhole is terminal.
ban.max_banned_fractionRefuse new bans once more than this share (01, per address family) of the protected space is blackholed — bounds the blast radius of a poisoned baseline or spoofed-source storm. 0 (default) disables it.
ban.max_bans_per_window / ban_window_secondsStorm guard: at most this many new bans per window (seconds). max_bans_per_window: 0 (default) disables it; the window is required when the rate is set.
ban.state_fileOptional writable path (e.g. /var/lib/kapkan/bans.json) where active bans are persisted so a restart rehydrates and re-announces them instead of dropping mitigation until re-detection. Pairs with bgp.graceful_restart. Empty (default) disables persistence. See Mitigation.
mitigationMitigation method: blackhole (default), flowspec (FlowSpec — a BGP extension that filters on more than the destination IP, e.g. port/protocol, so you can drop just the attack traffic), divert, or dataplane (drop in this host's own kernel, announcing nothing — requires a dataplane block with enabled: true, or the config is rejected at startup). Per-hostgroup overridable. See FlowSpec, Traffic diversion and In-kernel data plane.
flowspec.action / rate_mbpsFlowSpec rule action: discard or rate_limit (with rate_mbps). See FlowSpec.
flowspec.source_anchored / min_source_concentrationWhen on, a flowspec rule pins both the victim (destination) and a dominant attacker source from the attack sample — dropping only the attackers, sparing legitimate clients — but only when the sample is concentrated enough (default 0.8); otherwise it falls back to victim-anchored. Default off.
escalation[]Optional ladder of { after_seconds, action } rungs (none / dataplane / flowspec / divert / blackhole, in that order of severity) that supersedes mitigation. The ladder must be non-decreasing: it may hold or strengthen, never soften. A dataplane rung requires a dataplane block with enabled: true. See Escalation ladders.
dataplaneOptional in-kernel filter: Kapkan attaches an XDP program to this host's own NICs and drops attack traffic as the driver receives it, instead of announcing a route and asking a router to. Absent (the default) it is off entirely, and no other block references it. It needs Linux 5.15 or newer plus a systemd drop-in granting CAP_BPF, CAP_NET_ADMIN and CAP_PERFMON — read In-kernel data plane before you enable it.
dataplane.enabledDefaults to true when the block is present, so this key exists to switch a configured block off without deleting it. false resolves the block away entirely: a dataplane rung, mitigation: dataplane or carpet.mitigation: dataplane is then rejected at startup exactly as if the block were absent.
dataplane.interfaces / xdp_modeThe NICs to attach to (required, at least one; names must be unique and valid — up to 15 characters), and how: auto (default) tries native driver-level XDP and falls back to the generic path, native fails outright when the driver has no native XDP, generic forces the slower skb path (virtio, testing). Whether a named NIC actually exists is answered at attach time, by a startup error.
dataplane.pin_path / on_exitThe bpffs directory holding the pinned program and maps, so policy survives a restart of this process (default /sys/fs/bpf/kapkan; must be absolute, and Kapkan refuses to start unless it is mode 0700 and owned by the uid the daemon runs as). on_exit is what a clean shutdown does: keep (default) leaves the program attached and still enforcing static policy, detach removes it entirely.
dataplane.drop_malformedDrop frames that cannot be parsed instead of passing and counting them. Default false: this is a mitigation executor, not a firewall, so its default verdict is pass and unrecognised is not treated as hostile.
dataplane.allowlistSource prefixes — or bare addresses, taken as host routes — that always pass, checked before every other rule. Note the axis: this is who traffic comes from, whereas protected_whitelist names destinations that are never banned. Both are enforced in the kernel, and neither substitutes for the other. Empty by default; hot-reloads.
dataplane.ratelimit_profiles[]Named ceilings referenced by a static rule's profile: each entry has a unique name plus pps and/or mbps (at least one must be set; 0 leaves that dimension unlimited). A profile nothing references is dropped on reload. Empty by default; hot-reloads.
dataplane.static_rules[]Always-on operator rules, evaluated after the allowlist and before anything the detector installs. Each has a unique name, an action (pass / drop / ratelimit, the last requiring profile) and a match of src, proto (tcp / udp / icmp / icmp6), src_port, dst_port and payload. Match fields are ANDed and an unset field matches anything — so an empty match matches every packet, which only makes sense with ratelimit. Ports are rejected for icmp/icmp6. payload narrows on what the payload begins with; its only value is tls_client_hello (a TCP segment opening a TLS ClientHello — the shape of a handshake flood), it requires proto: tcp, and it never reassembles a stream, so a split ClientHello does not match and is forwarded. Rules are first match wins, so a rule whose match an earlier rule already covers can never fire; Kapkan reports such rules — and any the allowlist covers — on every apply rather than rejecting the config, see Rules that can never fire. Empty by default; hot-reloads.
dataplane.limits.max_dynamic_rules / max_static_rules / max_ratelimit_sourcesBPF map sizes, allocated in full when the program attaches (defaults 4096 / 256 / 1048576; each must be > 0). max_dynamic_rules bounds the rules the mitigator may install and must be at least ban.max_active_bans * 8, since one ban may install up to 8 match rules — Kapkan rejects a smaller value rather than letting installs fail mid-attack, and at the default max_dynamic_rules of 4096, 512 active bans is exactly the ceiling. max_static_rules must also cover the static_rules you wrote. max_ratelimit_sources sizes the per-source token-bucket table and is the only one of the three that costs real memory. See In-kernel data plane.
bgp.local_asn / router_id / next_hop / next_hop6 / communityBGP identity, IPv4/IPv6 blackhole next-hops, and the RTBH community — a route tag in ASN:value form (e.g. 65000:666) that your upstream matches to trigger the blackhole. router_id must be an IPv4 dotted-quad. next_hop6 is optional even when an IPv6 prefix is in networks; left unset, IPv6 blackholes use the RFC 6666 discard next-hop 100::1. See Mitigation.
bgp.communities / local_prefOptional community list (overrides community) and a LOCAL_PREF (a BGP route-preference attribute) for iBGP (internal BGP) peers — LOCAL_PREF only travels within your own network, not to external (eBGP) peers, so it is meaningful only to your internal neighbors. All bgp attributes are per-hostgroup overridable.
bgp.neighbors[]eBGP (external BGP) peers: address, remote_asn, and an optional port for testing.
bgp.graceful_restartBGP Graceful Restart (RFC 4724), on by default. A peer that supports it retains Kapkan's mitigation routes as stale while the session is down (e.g. an upgrade) instead of flushing them. Keys: enabled (default true), restart_seconds (default 120, range 04095, where 0 means "use the default 120"), long_lived (LLGR — Long-Lived Graceful Restart, default false) and long_lived_stale_seconds (default 3600, ≤ 86400). See Mitigation.
scrubbing.next_hop / next_hop6 / community / communities / local_prefThe scrubbing center the divert action steers traffic to. next_hop is required to divert (next_hop6 too when IPv6 is protected); the community is optional. Per-hostgroup overridable. See Traffic diversion.
scrubbing.nodes[] / node_selection / on_all_nodes_lost / stale_after_secondsManaged scrubbing nodes — boxes running kapkan scrub that Kapkan diverts to and tells what to drop. Each node has a name, next_hop/next_hop6, optional capacity_mbps and hostgroups. A group whose ladder diverts needs a node that serves it or the scalar next_hop. See Traffic diversion.
notify.*Telegram, Slack, email, webhook and exec-hook channels. See Notifications.
storage.clickhouse.*Optional ClickHouse persistence for attack and traffic history (url, database, username_env, password_env, ttl_days, …). See Storage.
geoipOptional MaxMind GeoLite2/GeoIP2 ASN (and country) attribution of attack-sample sources (enabled, asn_database, country_database). Off by default; fixed at startup.
api.listenREST API and metrics listen address. Default 127.0.0.1:8080.
api.dashboardServe the embedded web UI at / on the API listener. Default true. See Dashboard.
api.token_envNames the env var holding a single operator bearer token. Required before exposing the listener beyond localhost. See Authentication.
api.tokens[]Role-based tokens: each entry has a name, a token_env, a role (viewer or operator), and an optional tenant scope. Supersedes token_env (mutually exclusive). See Authentication and Multi-tenancy.
update_checkOptional, opt-in check for a newer Kapkan release — off by default, so Kapkan never phones home unless you enable it. Keys: enabled (default false), interval_seconds (default 21600 = 6h; must be >= 3600 / 1h or the config is rejected at load), channel (stable or prerelease), notify (also push "update available" to your notification channels the first time a new version is seen; default false) and an optional url mirror override. When on, it polls the GitHub Releases API and surfaces a result on /api/v1/status and the kapkan_update_available metric. The running version is always exposed locally regardless. You can also run kapkan -check-update to perform the check once on demand without enabling the poller (exit 10 = update available, 0 = up to date, 1 = error).

Thresholds detail

The three base thresholds — pps, mbps, flows_per_sec — are mandatory and must all be > 0. The per-protocol limits are optional refinements: tcp_syn_pps counts pure SYNs (SYN set, ACK clear) and frag_pps counts non-first IP fragments. Detection uses OR semantics — any single crossed threshold trips the attack. All values are expressed in real, unsampled units (see Sampling correction).

flows_per_sec is meaningful only for NetFlow/IPFIX exporters. sFlow exports one sample per packet (no flow aggregation), so sFlow-sourced hosts always report flows_per_sec = 0 and that threshold never trips for them — use pps/tcp_syn_pps/udp_pps instead. See Detection.

A complete example

A realistic production config, adapted from engine/deploy/config.example.yaml:

dry_run: true                       # keep until detection is validated

listen:
  sflow: ":6343"
  netflow: ":2055"                  # NetFlow v5/v9 + IPFIX share this socket

sampling:
  default_rate: 1000                # used only when an exporter omits its own rate
  # boundary: optional interface-boundary counting (deduplicates a flow seen at
  # several vantage points). List each exporter's external/uplink interfaces;
  # set egress_sampling only if calibration shows that ifIndex double-counts (see
  # Sampling correction) — not just because the device samples on egress.
  # boundary_debug: false
  # boundary:
  #   - exporter: "10.1.32.2"
  #     external_ifindexes: [1000025]
  #     egress_sampling: false   # true only if boundary_debug shows ~2x

networks:                           # detection applies ONLY inside these prefixes
  - "203.0.113.0/24"
  - "2001:db8::/32"

protected_whitelist:                # never banned, regardless of traffic
  - "203.0.113.1"                   # gateway / router
  - "203.0.113.2"                   # authoritative nameserver

thresholds:                         # per destination host, after sampling correction
  pps: 80000
  mbps: 1000
  flows_per_sec: 35000
  tcp_syn_pps: 5000                 # optional per-protocol; 0/absent disables
  udp_pps: 60000

thresholds_outgoing:                # detect compromised hosts attacking outward
  pps: 50000
  udp_pps: 20000

dataplane:                          # optional; needs Linux 5.15+ and the systemd drop-in
  interfaces: ["eth0"]              # required; at least one NIC (restart to change)
  # xdp_mode: auto                  # auto (default) | native | generic
  # pin_path: /sys/fs/bpf/kapkan    # bpffs dir; mode 0700, owned by the daemon's uid
  # on_exit: keep                   # keep (default) stays attached and enforcing on shutdown
  # drop_malformed: false           # default: unparseable frames pass and are counted
  allowlist:                        # SOURCE prefixes that always pass, checked first
    - "198.51.100.7"                # monitoring (a bare address is a host route)
    - "192.0.2.0/24"                # management network
  ratelimit_profiles:               # named ceilings; set pps, mbps or both
    - {name: icmp_cap, mbps: 10}
    - {name: handshake_cap, pps: 20}
  static_rules:                     # always-on, before any rule the detector installs
    - name: drop_chargen
      match: {proto: udp, src_port: 19}
      action: drop
    - name: cap_icmp
      match: {proto: icmp}
      action: ratelimit
      profile: icmp_cap             # must name a declared profile
    - name: cap_tls_handshakes      # per-source ceiling on new TLS handshakes
      match: {proto: tcp, dst_port: 443, payload: tls_client_hello}
      action: ratelimit
      profile: handshake_cap
  # limits:
  #   max_dynamic_rules: 4096       # must be >= ban.max_active_bans * 8
  #   max_static_rules: 256
  #   max_ratelimit_sources: 1048576  # per-source token buckets (kernel memory)

ban:
  ttl_seconds: 600                  # every ban auto-withdraws after this
  unban_hysteresis_seconds: 120     # stay below threshold this long before unban
  max_active_bans: 50               # refuse new bans past this cap
  # fallback: blackhole             # degrade a rejected flowspec/divert announce to RTBH (default)
  # max_banned_fraction: 0.05       # never blackhole more than 5% of protected space (per family)
  # max_bans_per_window: 100        # storm guard: at most 100 new bans per window
  # ban_window_seconds: 60          # the window for max_bans_per_window

bgp:
  local_asn: 65001
  router_id: "10.0.0.1"             # must be an IPv4 dotted-quad
  next_hop: "192.0.2.1"             # IPv4 blackhole (discard) next-hop
  next_hop6: "100::1"               # IPv6 blackhole next-hop
  community: "65000:666"            # RTBH community your upstream honors
  neighbors:
    - address: "10.0.0.254"
      remote_asn: 65000

notify:
  telegram:
    token_env: "KAPKAN_TG_TOKEN"    # token read from this env var, never the file
    chat_id: "-1001234567890"
  slack:
    webhook_url: ""                 # optional Slack incoming webhook
  email:
    smtp_host: ""                   # "mail.example.com:587"; empty disables
    from: ""
    to: []
    username_env: "KAPKAN_SMTP_USER"
    password_env: "KAPKAN_SMTP_PASS"
    require_tls: false              # STARTTLS auto-required when credentials are set

api:
  listen: "127.0.0.1:8080"          # default localhost bind needs no auth
  dashboard: true                   # embedded web UI at /; false = JSON API only
  # token_env: "KAPKAN_API_TOKEN"   # REQUIRED before exposing beyond localhost

The hostgroups, baseline and samples blocks are omitted here for brevity. See the dedicated pages for their full schemas:

  • Hostgroups — per-prefix thresholds, ban: false, and calculation: total.
  • Baselinesfactor, half_life_seconds, warmup_seconds, floor.
  • The samples block: enabled (default true), buffer_flows (default 65536, max 1048576) and flows_per_attack (default 20). These control the continuous flow ring used to attach dominant sources, ports and protocols to each attack the moment it trips. Changing the sizing requires a restart.

The dataplane block above is shown for shape. It is not self-sufficient: a host with dataplane.enabled: true fails at startup unless the kernel is 5.15 or newer and the shipped kapkan-dataplane.conf systemd drop-in is installed, because the hardened unit grants none of the three capabilities the XDP program needs. Install the drop-in with the config change, not after the first failed start — In-kernel data plane covers the host preparation, the map memory to budget for, and what each startup error means.

Sampling correction

Flow telemetry is sampled — a router exports one record per N packets. Kapkan multiplies every observed rate by the exporter's sampling rate so thresholds are expressed in real, unsampled traffic units. The rate comes from the flow packet when the exporter reports it, otherwise from sampling.default_rate.

This means a threshold like pps: 80000 is the real attack rate you want to act on, not a sampled count. If your exporter samples at 1:1000 and does not advertise the rate, set sampling.default_rate: 1000 so a single sampled packet-per-second is correctly counted as 1000 real packets-per-second.

!Get sampling right before going live

An incorrect sampling.default_rate scales every threshold by the same factor. Set it to match what your routers actually export, then confirm detection fires at the rates you expect in dry-run.

Counting each packet once

Correction assumes each packet is observed once. When the same packet is sampled at more than one vantage point — redundant exporters (e.g. an MLAG pair), ingress and egress sampling on one device (Arista sflow sample output), or transit/peer links — Kapkan would otherwise sum every copy and over-count pps, mbps and flows_per_sec by a constant factor.

Interface-boundary counting fixes this without changing the exporter. Classify each exporter's external (uplink/border) interfaces; a flow is then counted toward a protected host only when it crosses the boundary — inbound on an external input interface, outbound on an external output one. Exporters without a boundary entry keep counting every sample, so you can enable this one exporter at a time.

sampling:
  default_rate: 2500
  boundary:
    - exporter: "10.1.32.2"          # one entry per flow exporter (sampler/agent IP)
      external_ifindexes: [1000025]  # the uplink/border interface ifIndex values
      egress_sampling: false         # only if calibration shows this ifIndex double-counts (see below)

To discover which interfaces are external, set sampling.boundary_debug: true briefly and read kapkan_engine_boundary_debug_bytes_total broken down by exporter and interface.

!Calibrate egress_sampling — do not assume it

egress_sampling: true halves the boundary-counted rate to undo an in+out double-count, but sflow sample output does not automatically mean you need it. Whether an egress copy actually inflates the boundary depends on topology: on an Arista MLAG the egress copy of an inbound packet is sampled on the access port with a different input interface, so it never enters the boundary's input-interface gate — the packet is already counted once, and egress_sampling: true would then under-count it by half. Calibrate: compare the external interface's boundary_debug rate to that interface's real counter (e.g. show interfaces). Set egress_sampling: true only if the boundary interface shows ~2x the real rate; otherwise leave it false (the default).