GitHub

REST API

Kapkan exposes a small JSON REST API for observing detection and driving manual mitigation. It is served on the address in api.listen, which defaults to 127.0.0.1:8080. The same listener also serves the embedded dashboard and the Prometheus /metrics endpoint.

The API is read-mostly: status, active and recent attacks, the tracked-host snapshot, and the ban table are all GETs. Three POST endpoints mutate state — manual ban, manual unban, and config reload.

!Authenticate before exposing it

The default api.listen binds to 127.0.0.1, so the API is safe unauthenticated only on localhost. Before binding beyond loopback, set a bearer token. See Authentication.

All /api/v1 routes pass through the token check when auth is configured; once configured, every request must carry Authorization: Bearer <token>. Tokens carry a role: the GET routes need the viewer role, and the mutating routes (ban, unban, config/reload) need the operator role. config/reload goes further: it requires an unscoped (admin) token, so even an operator token that is tenant-scoped is refused with 403. The scrub-node channel takes the agent role — a scrub node's credential that reaches those routes and nothing else. A read with a viewer token works; a mutation with a viewer token returns 403; an unknown token returns 401. The POST endpoints additionally require a JSON content type — send Content-Type: application/json. A POST without that header is rejected with HTTP 415 Unsupported Media Type, so if a curl ban fails with a 415, that missing header is almost always why. Note that /metrics and the static dashboard shell are served without a token; the data the dashboard loads is fetched through the guarded API. See Authentication for the token roles.

A token may also be tenant-scoped: it then sees only its tenant's rows on every read endpoint (/status is rebuilt to its own hostgroups and counts), and may ban/unban only within its tenant — an out-of-tenant target returns a uniform 403. A tenant-scoped token cannot reload config at all: config/reload is restricted to unscoped (admin) tokens and returns 403 otherwise. See Multi-tenancy.

Endpoints

MethodPathDescription
GET/api/v1/statusMode, uptime, protected networks, thresholds, hostgroups, active attack/ban counts.
GET/api/v1/attacksActive attacks plus the last 100 that ended, with samples and classification.
GET/api/v1/hostsTracked-host snapshot: per-direction rates, learned baselines, attack state.
GET/api/v1/bansAll bans, active and historical.
GET/api/v1/trafficPersisted per-host rate history (viewer); {available:false, points:[]} when storage is disabled.
GET/api/v1/auditOperator-attributed audit trail of mutations (who banned/reloaded, when, outcome).
POST/api/v1/banManually ban an address.
POST/api/v1/unbanManually withdraw a ban.
POST/api/v1/config/reloadRe-read the config file (same as SIGHUP).
GET/api/v1/dataplane/rulesThe scrub-node rule feed (agent/unscoped operator); ETag long-poll, held up to 30 s.
POST/api/v1/dataplane/nodes/{name}/reportA scrub node's advisory self-report (agent/unscoped operator).
GET/api/v1/dataplane/nodesManaged-node inventory: liveness, config, per-node ban counts (unscoped viewer+).
GET/metricsPrometheus metrics.
GET/healthzUnauthenticated liveness/readiness probe: 503 until fully started, then 200.

All responses are JSON. Errors return an object of the form {"error": "..."} with an appropriate HTTP status.

GET /api/v1/status

Returns the current operating mode and a summary of what Kapkan is protecting. To read it (drop the Authorization header if no token is configured):

curl -fsS localhost:8080/api/v1/status \
  -H "Authorization: Bearer $TOKEN" | jq
{
  "dry_run": true,
  "uptime_seconds": 8123,
  "version": "v1.2.0 · a1b2c3d",
  "update_available": false,
  "role": "operator",
  "unscoped": true,
  "networks": ["203.0.113.0/24", "198.51.100.0/24"],
  "active_attacks": 1,
  "active_bans": 1,
  "thresholds": {
    "pps": 80000,
    "mbps": 1000,
    "flows_per_sec": 20000
  },
  "hostgroups": [
    { "name": "web", "calculation": "per_host", "mitigation": "blackhole", "ban": true }
  ]
}

dry_run reports the global mode: when true, no blackhole is announced to your routers. version is the running build, always present with zero egress. update_available is false unless the opt-in update_check is enabled and finds a newer release — then it is true and latest_version, latest_is_security and latest_url are included alongside it. See Upgrading. thresholds and hostgroups mirror the active configuration; see Detection and Hostgroups for their full shape. role and unscoped are always present — they tell the dashboard which token it is using. The networks, thresholds, bgp, scrubbing and notify fields are deployment-wide config and are returned only to an unscoped (admin) token; a tenant-scoped token receives a status object without them (just dry_run, version, uptime_seconds, update_available, role, unscoped, its own hostgroups, and counts). An admin token additionally gets bgp, scrubbing and notify objects — notify exposes only which channels are enabled, never tokens or URLs. So if a field looks "missing", check whether your token is tenant-scoped rather than assuming Kapkan is broken.

GET /api/v1/attacks

Returns currently active attacks plus the last 100 that ended (newest first). Both arrays hold Attack objects.

FieldTypeNotes
scopestringhost or group. Group-scoped attacks carry no target.
targetstringThe attacked address (host scope).
groupstringHostgroup name (group scope, or the host's group).
tenantstringThe owning group's tenant, when one is labeled (see Multi-tenancy).
directionstringincoming or outgoing.
metricstringThe metric that tripped, e.g. pps, mbps, tcp_syn_pps.
ratenumberThe rate of the tripping metric, in its unit: the engine's current measurement while the attack is active, the last one before it ended afterwards.
thresholdnumberThe threshold that was crossed.
ratesobjectFull per-protocol rate breakdown (see below), from the same measurement as rate.
activebooltrue while ongoing.
ban_statestringactive, withdrawn, or rejected. Omitted when no ban.
methodstringMitigation method: blackhole, flowspec, or divert. Omitted when no ban.
routestringThe route string (blackhole … / divert …), or a flowspec: ... summary, when a ban exists.
flowspecarrayThe generated FlowSpec rules, when method is flowspec.
dry_runboolWhether the ban was virtual.
started_atstringRFC 3339 timestamp.
ended_atstringRFC 3339 timestamp; omitted while active.
sampleobjectFlow sample captured at detection; omitted when sampling is off.
classificationobjectInferred attack vector; omitted when unclassified.
reasonobjectWhy the detection fired — threshold provenance, warm-up, protocol shares. Attached at start.
{
  "active": [
    {
      "scope": "host",
      "target": "203.0.113.66",
      "group": "web",
      "direction": "incoming",
      "metric": "pps",
      "rate": 412000,
      "threshold": 80000,
      "rates": {
        "pps": 412000,
        "mbps": 3100,
        "flows_per_sec": 9800,
        "udp_pps": 405000,
        "udp_mbps": 3080
      },
      "active": true,
      "ban_state": "active",
      "route": "blackhole 203.0.113.66/32 next-hop 192.0.2.1 community 65000:666",
      "dry_run": false,
      "started_at": "2026-06-13T09:41:07Z",
      "sample": {
        "top_sources": [
          { "key": "198.51.100.23", "packets": 1240000, "bytes": 1612000000 }
        ],
        "top_src_ports": [
          { "key": "123", "packets": 1180000, "bytes": 1534000000 }
        ],
        "top_dst_ports": [
          { "key": "443", "packets": 1240000, "bytes": 1612000000 }
        ],
        "protocols": [
          { "key": "udp", "packets": 1240000, "bytes": 1612000000 }
        ],
        "total_packets": 1240000
      },
      "classification": {
        "type": "ntp_amplification",
        "confidence": 0.95,
        "src_port": 123
      }
    }
  ],
  "recent": []
}

While an attack is active, rate and rates are re-read from the engine on every request, so they follow the attack instead of reporting the instant it was detected — when the sliding window held a single second and understated a sustained flood several-fold. metric and threshold stay as captured at detection: they name what tripped, and the engine judges the attack's end against the thresholds frozen at its start.

The rates object carries the base trio (pps, mbps, flows_per_sec) plus the per-protocol fields that are nonzero: tcp_pps, tcp_mbps, udp_pps, udp_mbps, icmp_pps, icmp_mbps, tcp_syn_pps, tcp_syn_mbps, frag_pps, frag_mbps. The sample object summarizes the buffered flows behind the detection — top_sources, top_src_ports, top_dst_ports, protocols (each a list of {key, packets, bytes} counters), an optional raw flows list, and total_packets. The classification type is one of the vectors documented in Detection; confidence is the share (0..1) of attack traffic matching the winning signature, and src_port is the reflected service port for amplification vectors.

The reason object explains why the detection fired — captured once at the start, off the hot path. threshold_source is static or baseline: whether the crossed limit came from the static config or a warmed-up learned baseline. When it is baseline, a baseline object carries the effective math (min(ceiling, max(floor, normal × factor))). baseline_configured, warming_up and warmup_remaining_seconds explain a static threshold that applied only because the baseline had not warmed up yet. shares is the per-protocol fraction of total PPS that drove classification, and dominant_share_gate is the share one protocol needs to win a vector (otherwise the attack is mixed). See Detection for how to read it.

{
  "reason": {
    "threshold_source": "baseline",
    "baseline": { "normal": 1200, "factor": 8, "floor": 5000, "ceiling": 80000 },
    "baseline_configured": true,
    "shares": { "udp": 0.98, "syn": 0, "tcp": 0.01, "icmp": 0, "frag": 0 },
    "dominant_share_gate": 0.5
  }
}

GET /api/v1/hosts

Returns a snapshot of every tracked host — the top-talkers data. Each entry is a HostStat.

FieldTypeNotes
targetstringThe host address.
groupstringThe host's hostgroup name.
ratesobjectCurrent incoming windowed rates.
rates_outobjectOutgoing rates; only nonzero when outgoing detection is on.
in_attackboolWhether the host is in any active attack.
metricstringThe metric of the active attack; omitted when not in attack.
directionstringThe active attack's direction; omitted when not in attack.
baselineobjectLearned incoming baseline; present when baselines are configured.
baseline_outobjectLearned outgoing baseline; present when baselines are configured.
{
  "hosts": [
    {
      "target": "203.0.113.66",
      "group": "web",
      "rates": {
        "pps": 412000,
        "mbps": 3100,
        "flows_per_sec": 9800,
        "udp_pps": 405000
      },
      "rates_out": {
        "pps": 120,
        "mbps": 2
      },
      "in_attack": true,
      "metric": "pps",
      "direction": "incoming",
      "baseline": {
        "pps": 950,
        "mbps": 7,
        "flows_per_sec": 60
      },
      "baseline_out": {
        "pps": 110,
        "mbps": 2,
        "flows_per_sec": 14
      }
    }
  ]
}

The baseline and baseline_out objects carry the learned-normal base trio (pps, mbps, flows_per_sec) and appear only while EWMA baselines are configured for the host's group. See Baselines.

GET /api/v1/bans

Returns the full ban table — active and historical. Each entry is a Ban.

FieldTypeNotes
targetstringThe banned address.
prefixstringThe blackhole prefix (/32 or /128).
metricstringThe metric that triggered the ban; omitted for manual bans.
ratenumberObserved rate at ban time; omitted when zero.
thresholdnumberThreshold crossed; omitted when zero.
next_hopstringThe discard next-hop.
communitystringThe community set attached to the route — the RTBH community for a blackhole, the divert (scrubbing) community for a divert ban — space-joined when more than one.
local_prefnumberThe LOCAL_PREF attached to the route; omitted when zero.
nodestringThe managed scrubbing node this divert ban is frozen to; omitted when the ban does not divert to a managed node.
routestringThe full route string, or a flowspec: ... summary for FlowSpec bans.
statestringactive, withdrawn, or rejected.
dry_runboolWhether the ban was virtual.
manualbooltrue for operator-requested bans.
started_atstringRFC 3339 timestamp.
expires_atstringTTL expiry; bans are never permanent.
withdrawn_atstringWhen the route was withdrawn; omitted while active.
reasonstringWhy a ban was rejected or withdrawn; omitted otherwise.
methodstringMitigation method: blackhole, flowspec, or divert.
flowspecarrayThe generated FlowSpec rules, when method is flowspec.
escalationarrayThe configured escalation ladder, when one is set.
escalation_stepnumberIndex of the ladder's current rung.
{
  "bans": [
    {
      "target": "203.0.113.66",
      "prefix": "203.0.113.66/32",
      "metric": "pps",
      "rate": 412000,
      "threshold": 80000,
      "next_hop": "192.0.2.1",
      "community": "65000:666",
      "route": "blackhole 203.0.113.66/32 next-hop 192.0.2.1 community 65000:666",
      "state": "active",
      "dry_run": false,
      "manual": false,
      "started_at": "2026-06-13T09:41:07Z",
      "expires_at": "2026-06-13T10:41:07Z"
    }
  ]
}

See Mitigation for how TTL, hysteresis and the ban cap shape this lifecycle.

GET /api/v1/audit

Returns the operator-attributed audit trail — who issued each ban, unban or config_reload, when, and the outcome (including refused actions). Records are newest-first and the endpoint is tenant-scoped server-side. It takes optional from/to (RFC 3339), action (ban/unban/config_reload) and target (an IP) query params, and defaults to the last hour:

{
  "available": true,
  "events": [
    {
      "event_time": "2026-06-22 03:14:09",
      "action": "ban",
      "result": "rejected",
      "operator": "alice",
      "role": "operator",
      "tenant": "customerA",
      "target": "203.0.113.66",
      "target_type": "host",
      "reason": "whitelisted",
      "source": "api",
      "ban_state": "rejected",
      "dry_run": 0
    }
  ]
}

When storage is disabled the response is {"available": false, "events": []}. Note that audit records encode dry_run as an integer (0/1), not the JSON boolean used elsewhere on this page. See the dedicated Audit log page for the full field list, the query rules, and the operator-identity and tenant-scoping model.

POST /api/v1/ban

Manually blackholes an address. The body is a single IP:

POST /api/v1/ban
Content-Type: application/json

{"ip": "203.0.113.66"}

As a runnable command (drop the Authorization header if you have not configured a token yet):

curl -fsS -X POST localhost:8080/api/v1/ban \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"ip":"203.0.113.66"}'

The response is the resulting Ban object. A manual ban honors every safety rule that an automatic ban does:

  • A whitelisted target is refused with HTTP 409 Conflict and reason: "whitelisted"; it is never announced.
  • A target outside the configured networks is refused with HTTP 409 and reason: "outside configured networks".
  • A request that would exceed max_active_bans is refused with HTTP 409 and reason: "max_active_bans reached".

In each 409 case the body is still a Ban object with state: "rejected" and the reason field set. An invalid or unparseable IP returns HTTP 400.

!Dry-run still applies

A manual ban respects the global mode. While dry_run is true, the ban is recorded and returned with dry_run: true but no route is announced. See the Safety model.

POST /api/v1/unban

Withdraws an active ban for the given address. The body is the same {"ip": "..."} shape and the same JSON content type is required:

POST /api/v1/unban
Content-Type: application/json

{"ip": "203.0.113.66"}

On success it returns the withdrawn Ban. If there is no active ban for the address, it returns HTTP 404.

POST /api/v1/config/reload

Re-reads the config file from disk and applies it — the same effect as sending SIGHUP. The body is empty; the JSON content type is still required. Unlike ban/unban, this endpoint requires an unscoped (admin) token: a reload swaps the whole config, so a tenant-scoped token is refused with 403 and config reload is restricted to unscoped (admin) tokens.

{
  "reloaded": true,
  "dry_run": false,
  "thresholds": {
    "pps": 80000,
    "mbps": 1000,
    "flows_per_sec": 20000
  }
}

If the new config fails to parse or validate, the running config is left untouched and the endpoint returns HTTP 400 with the error. See Configuration.

The scrub-node channel

These three endpoints exist for managed scrubbing nodes — boxes running kapkan scrub. They take the agent role (a scrub node's credential; see authentication) or an unscoped operator token, never a viewer or a tenant-scoped token: the documents span every tenant, so scoping them is a fleet concern, not a per-request one.

GET /api/v1/dataplane/rules

The rule table a node enforces: every active diverted victim, with the FlowSpec rules to drop and the TTL to mirror. A plain GET returns the current document with a content-hash ETag; a GET whose If-None-Match names that ETag is held until the table changes or up to 30 s, then answered 304 — a long-poll that gives sub-second rule updates over ordinary HTTP. A node identifies itself with ?node=<name>, and that poll is the node's liveness signal — so the name must be a configured node (an unknown name is 404) and, outside token-less local mode, the request must carry a real token.

{
  "version": 1,
  "bans": [
    {
      "target": "203.0.113.10",
      "prefix": "203.0.113.10/32",
      "method": "divert",
      "expires_at": "2026-08-13T10:00:00Z",
      "flowspec": [ { "dst": "203.0.113.10/32", "proto": 17, "action": "discard" } ]
    }
  ]
}

POST /api/v1/dataplane/nodes/{name}/report

A node's advisory self-report — version, XDP mode, node-side dry-run, load and drop totals — stored for the console. It is never a liveness signal (the poll is): a report can never keep a dead node marked up. The body is capped at 64 KiB (413 beyond) and an unknown {name} is 404. Success is 204 No Content.

GET /api/v1/dataplane/nodes

The node inventory the console's Nodes view renders: each configured node with what the brain knows (config, poll liveness, how many bans divert to it) joined with its last report. A viewer-rank read, but unscoped tokens only — the inventory names next-hops and hostgroups, which is deployment topology. The count alone (nodes_total) rides on /api/v1/status for every role, so a console can decide whether to show node affordances without this call.

GET /metrics

Serves Prometheus metrics in the standard text exposition format. This endpoint is not under /api/v1 and is served without the bearer token so a scraper can reach it. See Metrics for the full metric list.

GET /healthz

A liveness/readiness probe, not under /api/v1 and served without a bearer token (it leaks nothing) so a supervisor or the update.sh upgrade script can confirm the daemon is up after a restart. It returns 503 starting until every component has started, then 200 ok. Because the API listener only begins accepting once the daemon has started, any 200 here means the config parsed, the components are up, and Kapkan is serving.

curl -fsS localhost:8080/healthz   # exits non-zero (503) until ready, then prints "ok"
  • Authentication — set a bearer token before exposing the listener.
  • Audit log — the operator-attributed trail of mutations.
  • Dashboard — the embedded web UI served on the same address.
  • Metrics — the Prometheus /metrics endpoint.