GitHub

Notifications

Kapkan fires a notification on every attack lifecycle event — when an attack is first detected (attack_started) and when it clears (attack_ended). Each event is delivered to every configured channel: Telegram, Slack, email, a generic JSON webhook and an exec hook.

It also fires a one-off update_available event through those same channels when the opt-in update check finds a newer release and update_check.notify is enabled — see Upgrading. It carries the version fields instead of the attack fields, and the FastNetMon-format exec hook (which has no equivalent action) skips it.

Delivery is best-effort and asynchronous. A slow or failing channel never stalls detection or mitigation, and each channel is bounded by its own worker pool — under a carpet-bomb attack (one hitting many hosts in your range at once, so Kapkan fires one event per host), excess notifications are dropped and counted rather than piling up unbounded connections or hook processes.

iSecrets come from the environment

Bot tokens and SMTP credentials are read from named environment variables, never from the config file. The file holds only the name of the variable (for example token_env: "KAPKAN_TG_TOKEN"), so you can keep the config in git.

Channels

Telegram

Set notify.telegram.token_env to the name of the environment variable holding your bot token, and notify.telegram.chat_id to the target chat. The token is read from that variable at send time; it is never stored in the file. Messages are sent as HTML and include the attack target, classification, triggering metric and ban state.

iFinding the chat ID

To look up a chat's numeric ID, message @getmyid_bot directly for a personal chat, or add it to the target group to read the group ID (group IDs are negative, e.g. -1001234567890).

Slack

Set notify.slack.webhook_url to a Slack incoming webhook URL. Kapkan POSTs a formatted message to it on each event. Leave it empty to disable the channel.

Email (SMTP)

Configure notify.email to send SMTP mail. Set smtp_host to host:port (an empty value disables the channel), from to the envelope sender, and to to a list of recipients. Credentials are read from the environment via username_env and password_env.

KeyMeaning
smtp_hostSMTP server as host:port; empty disables email.
fromEnvelope and header sender address.
toList of recipient addresses.
username_envEnv var holding the SMTP username (enables AUTH PLAIN).
password_envEnv var holding the SMTP password.
require_tlsForce STARTTLS even without credentials.

Kapkan uses STARTTLS whenever the server advertises it. STARTTLS is required when credentials are configured (a non-empty username_env) or when require_tls: true — in that case, a server that does not offer STARTTLS is refused with a loud error rather than falling back to cleartext, which turns an active downgrade attack into a visible failure. When TLS is neither offered nor required, delivery to a non-loopback host proceeds in plaintext and is loudly logged.

!Plaintext to remote hosts is logged, not blocked

If your SMTP server offers no STARTTLS and you set no credentials and no require_tls, Kapkan will send mail in the clear to a non-loopback host and emit a warning. Set require_tls: true to make that case fail instead.

Webhook

Set notify.webhook.url to your receiver URL (use HTTPS — the payload can contain attack detail). Kapkan sends an HTTP POST with a Content-Type: application/json body — the callback payload described below — on every event (attack start, attack end, and update_available). Non-2xx responses are treated as a delivery error and counted. The request carries a bounded timeout, so a hung receiver cannot tie up a delivery slot.

!The webhook URL scheme is not validated

Unlike Slack (which Kapkan requires to be HTTPS), the generic webhook URL scheme is not checked. An http:// URL is accepted and the payload — which can carry attack targets and source detail — is sent in cleartext. Use an https:// endpoint.

POST /your/endpoint HTTP/1.1
Content-Type: application/json

Exec hook

Set notify.exec.command to the absolute path of an existing executable. On every event Kapkan runs it directly — no shell — with:

  • The callback payload JSON on stdin.
  • The event name (attack_started, attack_ended or update_available) as argv[1].
  • A minimal environment: only PATH, HOME, TZ, LANG, USER and TMPDIR are passed through. The daemon's secrets (bot tokens, SMTP credentials) are never inherited by the hook or its children.

The command must exist and be executable when the config loads. timeout_seconds (default 10) bounds each run; on timeout Kapkan kills the hook's whole process group so grandchildren cannot survive as orphans. The hook receives the same schema as the webhook, so a single script can serve both channels.

The invocation convention is selected by notify.exec.format: kapkan (the default, described above) or fastnetmon (see below).

!The hook runs as the daemon user

The exec command runs with the privileges of the Kapkan process. Treat the script as part of your trusted base, keep it on a path only root can write, and never read untrusted attacker-controlled fields back into a shell.

FastNetMon-compatible mode

Migrating from FastNetMon? Set notify.exec.format: fastnetmon and Kapkan invokes the hook exactly the way FastNetMon's notify_script does — so your existing notify scripts run unchanged:

  • argv is <ip> <direction> <pps> <action>.
  • stdin carries a plain-text attack summary (FastNetMon's attack_details body).
notify:
  exec:
    command: "/usr/local/bin/notify_about_attack.sh"
    format: fastnetmon

action is one of ban, unban, or attack_details, mapped from the Kapkan event:

Kapkan eventaction
attack started, host blackholed or divertedban
attack started, alert-only (no ban)attack_details
attack ended, a ban was withdrawnunban
any host-scoped event while dry_run is trueattack_details
group-scoped (calculation: total) eventskipped — hook not invoked
update_available eventskipped — no FastNetMon equivalent

!Dry-run never emits ban/unban

In dry-run Kapkan announces nothing — but a FastNetMon ban script would firewall the host. So in dry-run every event is downgraded to the informational attack_details, never ban/unban, and a FastNetMon script cannot block a host you are only validating. Group-scoped (calculation: total) attacks have no single host and are skipped in this mode.

Configuration

A complete notify block. Every channel is optional — an empty smtp_host, url, webhook_url or command disables that channel.

notify:
  telegram:
    token_env: "KAPKAN_TG_TOKEN"   # token is read from this env var, never the file
    chat_id: "-1001234567890"
  webhook:
    url: ""                        # generic JSON POST on every attack start/end
    # payload schema: docs/callback-schema.json (check schema_version)
  slack:
    webhook_url: ""                # Slack incoming webhook
  email:                           # SMTP; credentials from env vars
    smtp_host: ""                  # "mail.example.com:587"; empty disables
    from: ""
    to: []                         # ["ops@example.com"]
    username_env: "KAPKAN_SMTP_USER"
    password_env: "KAPKAN_SMTP_PASS"
    require_tls: false             # set true to fail (not send cleartext) if the server has no STARTTLS
  exec:                            # hook run on every attack event (no shell)
    command: ""                    # absolute path to an existing executable
    timeout_seconds: 10
    format: kapkan                 # or "fastnetmon" for FastNetMon-style argv/stdin

Callback payload schema

The webhook body and the exec hook's stdin carry the same JSON object. It is versioned by schema_version, currently 1. The authoritative definition lives in docs/callback-schema.json.

iForward compatibility

Consumers MUST check schema_version and SHOULD ignore unknown fields. The version is bumped only on breaking changes, so new fields can be added without breaking you.

FieldTypeNotes
schema_versionintegerAlways 1 in this release.
eventstringattack_started, attack_ended, or update_available (a newer release exists).
scopestringhost (a single target is under attack) or group (a calculation: total hostgroup's summed traffic; group set, target absent).
targetstringAttacked host — or, for outgoing attacks, the attacking host. Absent for group-scoped events.
groupstringOwning hostgroup name; global for the implicit fallback group.
directionstringincoming, or outgoing when the target originates the attack (likely compromised).
metricstringThe threshold that tripped (for example pps, mbps, udp_pps, tcp_syn_pps, frag_pps).
ratenumberObserved value of the triggering metric, sampling-corrected.
thresholdnumberConfigured limit of that metric at attack start.
ppsnumberSampling-corrected packets per second.
mbpsnumberSampling-corrected megabits per second.
flows_per_secnumberSampling-corrected flows per second.
ban_statestringactive, withdrawn or rejected, when mitigation was consulted.
methodstringblackhole, flowspec, or divert — the mitigation method that produced the ban; omitted when there is no ban.
routestringThe (dry-run: would-be) announcement: a blackhole route, or a flowspec: ... summary of the generated rules.
dry_runbooleanWhether mitigation was in dry-run for this event.
atstringRFC 3339 timestamp of the event.
classificationobjectInferred attack vector — attack_started only.
sampleobjectFlow sample captured before the threshold tripped — attack_started only, when the traffic buffer is enabled.
current_versionstringRunning build — update_available only.
latest_versionstringThe newer release tag — update_available only.
securitybooleantrue when the release is flagged security-relevant — update_available only.
release_urlstringThe release's URL — update_available only.

The classification object carries the inferred type (for example ntp_amplification, syn_flood, udp_flood, or mixed when no signature matched), a confidence share from 0 to 1, and src_port for amplification vectors. The sample object carries truncated flows plus top-K top_sources, top_src_ports, top_dst_ports and protocols counters and a total_packets denominator.

Sample payload

A realistic attack_started event for an NTP-amplification flood, detected in dry-run:

{
  "schema_version": 1,
  "event": "attack_started",
  "scope": "host",
  "target": "203.0.113.45",
  "group": "customers",
  "direction": "incoming",
  "metric": "mbps",
  "rate": 8421.5,
  "threshold": 500,
  "pps": 612000,
  "mbps": 8421.5,
  "flows_per_sec": 9400,
  "ban_state": "active",
  "method": "blackhole",
  "route": "blackhole 203.0.113.45/32 next-hop 192.0.2.1 community 65000:666",
  "dry_run": true,
  "at": "2026-06-13T14:22:07Z",
  "classification": {
    "type": "ntp_amplification",
    "confidence": 0.94,
    "src_port": 123
  },
  "sample": {
    "flows": [
      {
        "src": "198.51.100.7",
        "dst": "203.0.113.45",
        "src_port": 123,
        "dst_port": 51344,
        "proto": "udp",
        "fragment": false,
        "bytes": 4800,
        "packets": 10,
        "sampling_rate": 1024
      }
    ],
    "top_sources": [
      { "key": "198.51.100.7", "packets": 184320, "bytes": 88473600 }
    ],
    "top_src_ports": [
      { "key": "123", "packets": 611000, "bytes": 293280000 }
    ],
    "top_dst_ports": [
      { "key": "51344", "packets": 611000, "bytes": 293280000 }
    ],
    "protocols": [
      { "key": "udp", "packets": 611500, "bytes": 293520000 }
    ],
    "total_packets": 612000
  }
}