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).
POST/api/v1/dataplane/sourcesDrop a source in the data plane, scoped to one victim, with a mandatory TTL.
POST/api/v1/dataplane/sources/unblockRemove one source block immediately.
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/api/v1/edge/historyA zone's edge history bucketed over a range (viewer; a scoped token on its own zones); {available:false, points:[]} when storage is disabled.
GET/api/v1/edge/history/sourcesThe zone's telling sources over a range — who was denied, challenged or would have been (viewer; a scoped token on its own zones).
GET/api/v1/edge/eventsThe transitions the brain saw in the edge fleet, newest first (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, config_reload, source_block, source_unblock or edge_challenge, 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/source_block/source_unblock/edge_challenge) 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": []}. events is always an array — empty rather than null, including for a tenant whose window holds no record — so a client may iterate it without a guard. 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 source-block channel

Two endpoints let whoever already terminates a victim's traffic — an nginx in front of it, a log exporter, an operator at 3am — hand Kapkan a source to drop in the XDP data plane, scoped to that victim, with a mandatory TTL. The decision is made where the requests are visible; the enforcement happens in the kernel. Both require an operator token; the agent role is denied.

curl -fsS -X POST localhost:8080/api/v1/dataplane/sources \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"victim":"203.0.113.10","source":"198.51.100.7","ttl_seconds":600,"reason":"login flood"}'
FieldRequiredMeaning
victimyesThe protected destination the block is scoped to. It is what a tenant token is checked against, and what keeps one caller's evidence from becoming a global drop.
sourceyesThe address to drop. Must be the same address family as victim.
ttl_secondsyes for a blockHow long the block lives, from 1 to 86400 (24 h). There are no permanent entries. Blocking the same pair again keeps the original created_at and applies the new TTL — the refresh an exporter needs to hold a persistent offender without unblock/re-block churn.
reasonnoA free-text note carried into the audit trail.

The response is the resulting SourceBlock:

{
  "source": "198.51.100.7",
  "victim": "203.0.113.10",
  "created_at": "2026-08-17T21:04:11Z",
  "expires_at": "2026-08-17T21:14:11Z",
  "dry_run": false,
  "reason": "login flood"
}

POST /api/v1/dataplane/sources/unblock takes the same victim and source (any ttl_seconds is ignored) and removes that one pair immediately, so nobody waits out a TTL they typed themselves. It returns the removed SourceBlock, or 404 if no such pair is live.

Refusals

An input mistake is 400; a well-formed request refused by policy is 409. Every call — including a refusal — writes one attributed audit event, because a refused block is itself an operator action.

StatusCause
400Unparseable JSON or address, victim and source in different address families, or ttl_seconds outside [1, 86400].
403victim is outside your tenant. The same uniform refusal the ban endpoints give, so the endpoint is not an existence oracle for other tenants' hosts.
404Unblock only: no live block for that pair.
409The data plane is absent or disabled; victim is in protected_whitelist; source is in dataplane.allowlist; source is inside the protected networks; this source already holds 8 victims; or no policy slots are left.

The two allowlist refusals are the interesting ones, and they are refusals precisely because the alternative is silence: the datapath passes an allowlisted source at precedence 1 and protected-destination traffic at precedence 2, both before any rule is evaluated — so such a block would install and then never match a packet. A source inside your own networks is refused too: an internal host is a ban, not a source block.

!An install failure withdraws every pair for that source

One source's pairs share a single kernel policy block, and the installer's rollback is all-or-nothing across it. If an install fails, every pair for that source is withdrawn rather than left recorded-but-unenforced, and the error says so — re-add the ones you still need. The table never claims to be enforcing something the kernel is not.

iSame guarantees as a ban

TTLs are mandatory and bounded, each pair carries its own expiry into the kernel (so it lapses on schedule even if Kapkan dies), installs are accounted against dataplane.limits.max_dynamic_rules, blocks survive a restart through the state file, and dry_run is honoured — the pair is recorded and returned with dry_run: true and nothing reaches a kernel map. See Source blocks for the limits and Safety model.

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. A token bound to a node (api.tokens[].node) may only poll as that node: another name, or no name, is a uniform 403 before any presence is recorded. A reload that removes the node, removes the token or moves the token's binding ends a parked poll with the answer a first poll would now get (404, 401, 403). Presence is stamped only by agent tokens — an operator's ?node=X is a preview of X's document and moves no liveness.

{
  "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.

The edge channel

These endpoints exist for edge nodes — boxes running kapkan edge. Like the scrub-node channel, the node-facing endpoints take the agent role or an unscoped operator token, never a viewer or a tenant-scoped token: the zones document spans every tenant. The human-facing ones differ: the node inventory and the fleet's events (GET /api/v1/edge/events) are viewer-rank reads for unscoped tokens; the zone status, the edge history with its telling sources (viewer rank) and the lever (operator rank) admit a tenant-scoped token for its own zones. The brain distributes policy, never verdicts — no per-request call exists here or anywhere; a node answers requests alone. The count of configured edge nodes (edge_nodes_total) rides on /api/v1/status for every role, so a console can decide whether to show the Edge view without any of these calls.

GET /api/v1/edge/zones

The zone document a node renders and enforces: the zones of the zones file the node's placement scope covers — every zone for an operator's bare GET, or for a fleet whose zones carry no hostgroup (a labelled zone is served only by nodes that list its group) — with their origins, TLS floor, ACME directories and policy, plus the live issuance grants, fanned-out ACME challenges, clearance keys and levers of those zones; the ETag is the node's own. A plain GET returns the document with a content-hash ETag; a GET whose If-None-Match names that ETag is held until the document changes — a successful reload of the zones file, a certificate challenge published by a node — or up to 25 s, then answered 304. A reload that removes the node, removes the token or moves the token's binding ends a parked poll with the answer a first poll would now get (404, 401, 403), never a document. A node identifies itself with ?node=<name>, and that poll is the node's liveness signal, so the name must be a configured edge.nodes[] entry (an unknown name is 404) and the request must carry a real token. A token bound to a node (api.tokens[].node) may only poll, report and coordinate ACME as that node — another node's name, or a poll without ?node=, is a uniform 403 that never names the bound node, refused before any presence is stamped or anything stored; only agent tokens stamp presence, so an operator's ?node=X is a presence-free preview of X's document. At most 4 polls per token name and max(8, 2 × the number of edge nodes) in total may be parked at once; a further matching poll is answered 429 with Retry-After: 1 (too many concurrent zone holds), which the node treats as a failure and backs off from (1 s doubling to 30 s) — give each node its own agent token.

{
  "version": 1,
  "zones": [
    {
      "name": "shop.example.com",
      "origins": ["10.0.0.10:8080"],
      "tls": { "min_version": "1.2" },
      "policy": { "mode": "decide", "failure_mode": "open", "challenge": "auto", "rate": { "rps": 50, "concurrency": 20 } }
    }
  ],
  "acme_challenges": [],
  "issuance_grants": []
}

acme_directory, acme_fallback, extra_directives_file and tls.h3 are omitted when unset; tls.h3_options {advertise, alt_svc_max_age_seconds} appears only where a zone departs from the defaults (advertise: false, or a non-default ma) — absent means announce, 86400 — so a zones file that merely turns h3 on leaves the document's bytes and ETag otherwise unchanged. An absent acme_directory means the node orders from its own default CA (Let's Encrypt unless its edge.yaml says otherwise). The policy carries dry_run and the challenge_options a zone sets; clearance_keys (not shown) are the rung's per-zone signing keys (the current and the previous UTC day's, each honoured 48 h — the document carries secrets, and a node caches it 0600); a zone an operator has pulled the lever on carries challenge_override {mode, until, reason} with a fixed until, so the document's bytes and ETag move exactly when the operator acts and once more when it lapses.

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

A node's advisory self-report — version, node-side dry-run, the ETag of the document it has rendered (so an operator can see a node lagging or refusing), the terminator's kind, version and liveness, its HTTP/3 readiness from the node's nginx -V probe (terminator.h3 {state, module, tls_library, early_data_capable, advisory, serving, unsupported, listening}state is ready, no_module, node_off or unknown; advisory names a published QUIC advisory whose range holds the build's nginx core, as advice: distributions backport fixes without moving the version; serving lists the zones the live generation listens over QUIC for, unsupported the zones that asked and are served over TCP here; listening says whether something on the box holds UDP 443 while QUIC listeners are rendered — the local half of "is HTTP/3 reachable?", the firewall being the other, and absent while no zone is served over QUIC here or on a box without /proc/net/udp), the live generation and its nginx -t result, and the certificates it holds (public metadata only, never a key; a long list is cut to the 64 KiB body limit with a certs_truncated count), and a zones section: for every deciding zone the live generation serves, the last closed ten-second window (at, window_seconds, rps, requests, decided, denied, challenged, cleared, would_deny, would_challenge, status_2xx..5xx, h3_requests — the requests that arrived over HTTP/3), the zone's challenge mode as the node applies it, whether the zone is watch-only on this node (dry_run) and whether its rung only previews there (rung_dry_run: the node's, the zone's or the rung's own challenge_options.dry_run), a zone-wide challenge in force (challenge_active {reason, until, dry_run}dry_run says the flip previews there), and the window's sources — the would-be ones first (a challenge or a deny previewed), then the refused and challenged, then the busiest of the rest — each with the strongest thing the node did to it (state: denied, challenged, would-deny, would-challenge, cleared, marked, allow). A report too big for the limit sheds detail a little at a time — the sources that tell nothing first (uncounted: they are not in the would-be set), then every zone's list halved, then certificates, then zones — each cut counted: sources_truncated is the would-be sources missing from the list (the ones the node's per-window bound of 20 left out, and the ones a shrinking report shed), certs_truncated and zones_truncated the entries dropped. Stored and returned by GET /api/v1/edge/nodes. With storage on, the report's windows and telling sources are also written to the edge history and what changed since the node's previous report becomes events — after the report is stored and without the answer ever waiting for it. Never a liveness signal (the poll is). An unknown {name} is 404; a body over 64 KiB is 413 report exceeds 64 KiB — the limit a node sheds detail to stay under, so a 413 here is a client that did not, and nothing of the report is read; success is 204 No Content.

GET /api/v1/edge/nodes

The node inventory: each configured edge node with its poll liveness joined with its last report, its placement scope (hostgroups — the effective scope, ["global"] for a node without one — and zones_placed, the zones of the file it covers), plus tokens (the agent token names bound to it with api.tokens[].node) and last_token (the name of the token that last polled as it — never a secret); the document carries unbound_agent_tokens while any agent token is unbound and nodes exist to bind it to (absent otherwise) — the migration view of Binding an agent token to its node. hostgroups and zones_placed are always present; tokens, last_token, last_seen and unbound_agent_tokens are omitted when empty — last_seen is there once the node has polled this brain, and absent for one never seen since the brain started. The document also carries nodes_total and the stale_after_seconds the judgment uses; each node carries alive and holding.

A sighting is stamped when a poll starts and again when it ends, so while a poll is parked last_seen holds that poll's start and is up to 25 s old on a perfectly healthy node — older than stale_after_seconds on the defaults. holding says a poll is parked right now, and a node parked in one is alive whatever last_seen says; alive is parked, or seen within stale_after_seconds, never anything the node's own report claims. The consequence to plan around: a change that cuts a node off — a token removed, a binding moved — wakes its parked poll at once and ends it with the refusal a fresh poll would get, which stamps a sighting of its own, so alive turns false stale_after_seconds after the change — about 15 s on the defaults, not a hold and a window: the refusal lands at the reload, never at the hold's deadline. A viewer-rank read, unscoped tokens only.

GET /api/v1/edge/zones/status

The zones across the fleet: the alive nodes' zones sections merged — per zone, the window figures summed (the windows are not aligned, so this is a fleet-wide rate to the nearest window), the zone's challenge mode, the nodes on which it is watch-only (watch_only) and those on which its rung only previews (rung_watch_only — a manual or auto zone challenges for real on the nodes not named there), the nodes with a zone-wide challenge in force and whether each bites or previews, the lever in force (override), the zone's HTTP/3 across the fleet (h3 {enabled, serving, unsupported, requests}enabled is the zones file's tls.h3 as the brain holds it, serving the alive nodes whose live generation listens over QUIC for the zone, unsupported those where it asked and is served over TCP, requests the nodes' last-window HTTP/3 requests summed; absent for a zone nobody speaks or asks h3 for. enabled appears for every zone of the file that turns h3 on — a mode: none zone included — and serving/unsupported name the alive nodes' live QUIC listeners for the zone from their terminator.h3, deciding zone or not; requests comes from the reports' zones section, which lists deciding zones only), and the would-be set: the union of the sources the nodes previewed a challenge or a deny for, the stronger state kept, the busiest first, bounded to 20 per reporting node (would_be_truncated counts the rest). partial says a node's would-be sources did not all fit — its per-window bound, or a report that had to shrink — so the set is what survived, not everyone; zones_truncated on the document sums the zone entries the alive nodes cut from their reports, zones that are missing or undercounted here — a fleet-wide count, taken before the tenant filter, so a scoped caller may see a number that covers zones it cannot see. Every zone of the zones file has a row as well — mode (its policy.mode) and file_challenge (its policy.challenge) are the brain's word, challenge what the nodes apply — and certs lists the certificate each alive node holds for the zone (node, not_after, issuer — the last omitted when the node reports none; metadata only, from the reports; certs_truncated on the document sums the entries the nodes cut from their reports to fit the size limit — fleet-wide, like zones_truncated — so a short list is what survived, not what is held). Every file zone also carries its placement {hostgroup, nodes, alive} — the group the file puts it in, the configured nodes whose scope covers it and which of them are alive — and unserved: true when it has nodes but none is alive (a zone no node's scope covers shows nodes: [] and is a -check-config warning instead). A node's claims about a zone outside its scope are stored in its report, and neither merged here nor written to the edge history (kapkan_edge_history_dropped_total{reason="outside_scope"}). placement.hostgroup is present for unscoped tokens only: a tenant sees the node names its zone is served on, not how the operator groups them. A viewer-rank read. An unscoped token sees every zone, labelled ones with their tenant (the key is absent on an unlabelled zone); a tenant-scoped token exactly its own zones — no other tenant's hostname in any row, would-be set or HTTP/3 list, and no tenant field. Node names are visible to a tenant; the inventory is not. This is edge-spec's who would have been challenged: read it before a zone's rung goes live.

{
  "nodes_alive": 2,
  "nodes_reporting": 2,
  "zones": [
    {
      "zone": "shop.example.com",
      "tenant": "shop",
      "mode": "decide",
      "file_challenge": "auto",
      "nodes": 2,
      "challenge": "auto",
      "rps": 812.4,
      "requests": 8124, "decided": 8124, "would_deny": 37, "would_challenge": 1240,
      "watch_only": ["edge-2"],
      "rung_watch_only": ["edge-2"],
      "challenge_active": [
        { "node": "edge-1", "reason": "zone-rps", "until": "2026-09-06T12:05:00Z", "dry_run": true }
      ],
      "would_be": [
        { "source": "203.0.113.7", "state": "would-deny", "requests": 1210, "nodes": ["edge-1", "edge-2"] },
        { "source": "198.51.100.23", "state": "would-challenge", "requests": 640, "nodes": ["edge-1"] }
      ],
      "certs": [
        { "node": "edge-1", "not_after": "2026-11-20T09:12:00Z", "issuer": "R11" },
        { "node": "edge-2", "not_after": "2026-11-20T09:12:00Z", "issuer": "R11" }
      ]
    }
  ]
}

A zone no alive node has reported yet — one in policy.mode: none, or one whose nodes are not up — still has its row: nodes: 0, rps: 0, its mode and file_challenge, and override when a lever is set. A lever on a zone a reload has since removed from the file keeps a row too, without mode (unscoped callers only — it is theirs to clear). The request counters (requests, decided, denied, challenged, cleared, would_deny, would_challenge) are omitted when zero; nodes and rps are always present.

GET /api/v1/edge/history

A zone's edge history: the nodes' ten-second windows summed into step-second buckets between from and to (RFC 3339; the last hour by default; at most 31 days; step defaults to 60 s, is raised so a range holds at most 5 000 buckets, and is at most 86 400 s — a day) — {available, zone, node, step_seconds, points: [{ts, nodes, window_seconds, requests, decided, denied, challenged, cleared, would_deny, would_challenge, status_2xx, status_3xx, status_4xx, status_5xx, h3_requests}]}. nodes counts the distinct nodes that reported in the bucket; step_seconds is the step actually applied; zone and node echo the request, node only when the filter was given; derive the rate as requests / window_seconds and the HTTP/3 share as h3_requests / requests. The range rules are shared with /api/v1/traffic and /api/v1/audit, and the step rules with /api/v1/traffic alone — the audit endpoint takes no step — one implementation, so a bad range gets the same message on all three. zone is required, folded to lower case like the file's names, and must be in the zones file (404 unknown zone otherwise); node=<name> narrows to one configured node (404 unknown edge node). viewer rank. A tenant-scoped token reads its own zones; any other zone — another tenant's, unlabelled, gone from the file or nonexistent — is one uniform 403 (zone is outside your tenant), counted in kapkan_api_zone_refused_total{route="edge_history"} and logged once a minute per token, and node= is for unscoped tokens only (a tenant gets nodes as a count, never one node's history). Storage off: {available: false, points: []}, never an error — the zone is not looked at, so every token gets that answer for any zone and nothing is counted or logged; a failed query is 502.

GET /api/v1/edge/history/sources

The zone's telling sources over the range — edge-spec's who would have been challenged, asked over a period rather than a ten-second window: {available, zone, state, sources: [{source, state, requests, windows, nodes, first_seen, last_seen}]}, the busiest first, at most 1 001 rows. Each source's state is the strongest thing any node did to it over the range (denied over challenged over would-deny over would-challenge); state= filters to one of those four (400 for anything else — visitors are never stored), and the top-level state echoes that filter, absent without one. Same zone, range and scope rules as the history above.

GET /api/v1/edge/events

The transitions the brain saw, newest first, at most 1 001: {available, events: [{event_time, node, zone, kind, detail}]}, with optional node=, zone= and kind= filters — kind is one of node_alive, node_lost, version, dry_run, document_rendered, generation_installed, generation_refused, terminator_alive, h3_state, cert_issued, cert_renewed, cert_gone, challenge_started, challenge_ended, clock_skew, report_truncated (400 otherwise). viewer rank, unscoped tokens only: the events name nodes and the fleet's changes. The same range rules; storage off answers {available: false, events: []}.

Every kind is a transition, so a state the brain has only ever seen once is not in here. The first report after a brain start is a silent baseline, and so is presence: a node is baselined at its first poll, or — if the brain never hears from it — as lost once stale_after_seconds has passed since the presence ticker started, without writing node_lost. A node that was down across a brain restart therefore has no event until it comes back, and its first is node_alive. Read the inventory, not this list, for what a node is now.

POST /api/v1/edge/nodes/{name}/acme/slot and …/acme/challenges

The issuance coordinator, advisory and in memory. A node asks for a per-zone slot before ordering a certificate ({"zone": "…"}{"granted": true, "expires_at": …} or {"granted": false, "holder": "…", "retry_after_seconds": …}; {"release": true} returns it), so a fleet does not burn a CA's duplicate-certificate ceiling in one afternoon; it waits at most 15 minutes and then orders anyway. A node that holds the slot publishes its pending HTTP-01 challenge ({"zone", "token", "key_authorization"}) and the brain fans it out in the document of every node that serves the zone for ten minutes, so each of them answers it — the CA's validation may land on any node behind the zone's shared address. A live challenge is never overwritten by a different key authorization (409), a node may hold 16 live challenges, and every call is logged with the node, the zone and the token's prefix: an agent token is a certificate-issuing credential, rotate it on any node compromise. Both take only the zones the node's placement scope covers: a zone the node does not serve is 404 unknown zone for it, byte for byte the answer a nonexistent zone gets (a {"release": true} is the exception: it touches only the caller's own grant, is idempotent, and is answered 204 for any zone name, served or not).

POST /api/v1/edge/zones/{name}/challenge and DELETE

The lever: set a zone's challenge mode for a bounded time, whatever the zones file says. operator rank. A tenant-scoped operator may pull it on its own zones only; any other zone — another tenant's, unlabelled, or gone from the file — is 404 unknown zone for it, the same answer an unknown name gets, decided before the body is read. The caller learns nothing more; the operator does: each such refusal counts in kapkan_api_zone_refused_total{route="edge_lever"} and is logged once a minute per token, and no audit row is written.

{ "mode": "manual", "ttl_seconds": 1800, "reason": "credential stuffing from residential proxies" }

mode is manual, auto or off (off clears the override, as DELETE does); ttl_seconds is 60..86400 and required unless mode is off; reason is for the log, the audit row and the API's answers — override.reason in GET /api/v1/edge/zones/status and this endpoint's own response (at most 200 characters). An unknown zone is 404; a zone in policy.mode: none is 409 — nothing challenges there. The override travels in the zones document as challenge_override (parked polls are woken at once) and every node that serves the zone applies the effective mode on its fast path, so it ends at until brain or no brain. Audited as edge_challenge set / cleared. Both methods answer 200 with the rung as it now stands and where the lever bites:

{
  "zone": "shop.example.com",
  "mode": "manual",
  "until": "2026-09-06T12:30:00Z",
  "reason": "credential stuffing from residential proxies",
  "file_mode": "auto",
  "zone_watch_only": false,
  "rung_watch_only": false,
  "nodes": [
    { "name": "edge-1", "alive": true, "dry_run": false },
    { "name": "edge-2", "alive": false }
  ]
}

file_mode is policy.challenge as the file has it; zone_watch_only and rung_watch_only are the zone's policy.dry_run and challenge_options.dry_run — on either the lever previews rather than bites, on every node; nodes[] lists the nodes the zone is placed on — every configured node for a zone a reload has since removed from the file — with its liveness and its reported dry_run (absent until it has reported) — a node that only counts must be seen before the lever is trusted. mode, until and reason are absent when the zone follows its file, and file_mode is empty for a zone a reload has since removed from the file — such a lever can still be cleared, never set. The lever is held in the brain's memory, like the issuance coordinator; it is not gated by the brain's own dry_run. A node running a version from before the lever does not know challenge_override and follows its file while nodes[] still lists it: upgrade the nodes first.

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.