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
| Method | Path | Description |
|---|---|---|
| GET | /api/v1/status | Mode, uptime, protected networks, thresholds, hostgroups, active attack/ban counts. |
| GET | /api/v1/attacks | Active attacks plus the last 100 that ended, with samples and classification. |
| GET | /api/v1/hosts | Tracked-host snapshot: per-direction rates, learned baselines, attack state. |
| GET | /api/v1/bans | All bans, active and historical. |
| GET | /api/v1/traffic | Persisted per-host rate history (viewer); {available:false, points:[]} when storage is disabled. |
| GET | /api/v1/audit | Operator-attributed audit trail of mutations (who banned/reloaded, when, outcome). |
| POST | /api/v1/ban | Manually ban an address. |
| POST | /api/v1/unban | Manually withdraw a ban. |
| POST | /api/v1/config/reload | Re-read the config file (same as SIGHUP). |
| POST | /api/v1/dataplane/sources | Drop a source in the data plane, scoped to one victim, with a mandatory TTL. |
| POST | /api/v1/dataplane/sources/unblock | Remove one source block immediately. |
| GET | /api/v1/dataplane/rules | The scrub-node rule feed (agent/unscoped operator); ETag long-poll, held up to 30 s. |
| POST | /api/v1/dataplane/nodes/{name}/report | A scrub node's advisory self-report (agent/unscoped operator). |
| GET | /api/v1/dataplane/nodes | Managed-node inventory: liveness, config, per-node ban counts (unscoped viewer+). |
| GET | /api/v1/edge/history | A 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/sources | The 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/events | The transitions the brain saw in the edge fleet, newest first (unscoped viewer+). |
| GET | /metrics | Prometheus metrics. |
| GET | /healthz | Unauthenticated 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.
| Field | Type | Notes |
|---|---|---|
scope | string | host or group. Group-scoped attacks carry no target. |
target | string | The attacked address (host scope). |
group | string | Hostgroup name (group scope, or the host's group). |
tenant | string | The owning group's tenant, when one is labeled (see Multi-tenancy). |
direction | string | incoming or outgoing. |
metric | string | The metric that tripped, e.g. pps, mbps, tcp_syn_pps. |
rate | number | The 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. |
threshold | number | The threshold that was crossed. |
rates | object | Full per-protocol rate breakdown (see below), from the same measurement as rate. |
active | bool | true while ongoing. |
ban_state | string | active, withdrawn, or rejected. Omitted when no ban. |
method | string | Mitigation method: blackhole, flowspec, or divert. Omitted when no ban. |
route | string | The route string (blackhole … / divert …), or a flowspec: ... summary, when a ban exists. |
flowspec | array | The generated FlowSpec rules, when method is flowspec. |
dry_run | bool | Whether the ban was virtual. |
started_at | string | RFC 3339 timestamp. |
ended_at | string | RFC 3339 timestamp; omitted while active. |
sample | object | Flow sample captured at detection; omitted when sampling is off. |
classification | object | Inferred attack vector; omitted when unclassified. |
reason | object | Why 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.
| Field | Type | Notes |
|---|---|---|
target | string | The host address. |
group | string | The host's hostgroup name. |
rates | object | Current incoming windowed rates. |
rates_out | object | Outgoing rates; only nonzero when outgoing detection is on. |
in_attack | bool | Whether the host is in any active attack. |
metric | string | The metric of the active attack; omitted when not in attack. |
direction | string | The active attack's direction; omitted when not in attack. |
baseline | object | Learned incoming baseline; present when baselines are configured. |
baseline_out | object | Learned 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.
| Field | Type | Notes |
|---|---|---|
target | string | The banned address. |
prefix | string | The blackhole prefix (/32 or /128). |
metric | string | The metric that triggered the ban; omitted for manual bans. |
rate | number | Observed rate at ban time; omitted when zero. |
threshold | number | Threshold crossed; omitted when zero. |
next_hop | string | The discard next-hop. |
community | string | The 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_pref | number | The LOCAL_PREF attached to the route; omitted when zero. |
node | string | The managed scrubbing node this divert ban is frozen to; omitted when the ban does not divert to a managed node. |
route | string | The full route string, or a flowspec: ... summary for FlowSpec bans. |
state | string | active, withdrawn, or rejected. |
dry_run | bool | Whether the ban was virtual. |
manual | bool | true for operator-requested bans. |
started_at | string | RFC 3339 timestamp. |
expires_at | string | TTL expiry; bans are never permanent. |
withdrawn_at | string | When the route was withdrawn; omitted while active. |
reason | string | Why a ban was rejected or withdrawn; omitted otherwise. |
method | string | Mitigation method: blackhole, flowspec, or divert. |
flowspec | array | The generated FlowSpec rules, when method is flowspec. |
escalation | array | The configured escalation ladder, when one is set. |
escalation_step | number | Index 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 Conflictandreason: "whitelisted"; it is never announced. - A target outside the configured
networksis refused with HTTP409andreason: "outside configured networks". - A request that would exceed
max_active_bansis refused with HTTP409andreason: "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"}'
| Field | Required | Meaning |
|---|---|---|
victim | yes | The 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. |
source | yes | The address to drop. Must be the same address family as victim. |
ttl_seconds | yes for a block | How 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. |
reason | no | A 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.
| Status | Cause |
|---|---|
400 | Unparseable JSON or address, victim and source in different address families, or ttl_seconds outside [1, 86400]. |
403 | victim is outside your tenant. The same uniform refusal the ban endpoints give, so the endpoint is not an existence oracle for other tenants' hosts. |
404 | Unblock only: no live block for that pair. |
409 | The 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"
Related
- 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
/metricsendpoint.