Edge nodes (managed HTTPS front)
iWhat this is — and is not
An edge node is a Linux box running nginx or Angie that Kapkan manages: it issues the zones' certificates on the box, generates the terminator's configuration, decides per request whether to let a client through — or to ask it to prove it is a browser first — and reports back. Kapkan never forwards a client's bytes — nginx terminates TLS and proxies to your origins; Kapkan tells it what to serve and, for each request, whether to serve it. You bring the network (anycast, transit, DNS) and the boxes; Kapkan brings the protection loop.
In plain terms: you list your websites (zones) in one file on the brain, point their DNS at your edge boxes, and each box gets a certificate, a generated nginx configuration and a local decision service that counts every client, refuses the ones that exceed the zone's policy and, where you allow it, asks a suspicious one to solve a small puzzle before it is blocked — without a round trip to anything.
# zones.yaml on the brain (edge.zones_file) — see the zones reference
zones:
- name: shop.example.com
origins: ["10.0.0.10:8080", "10.0.0.11:8080"]
tls: { min_version: "1.2" }
policy:
mode: decide # every request asks the local decision service
failure_mode: open # decider down → requests pass, undecided
rate: { rps: 50, concurrency: 20 }
challenge: auto # a flooder is asked to prove it is a browser before it is blocked
challenge_options:
dry_run: true # watch-only: shows who WOULD be challenged, challenges nobody
The role is the third one of the same binary: kapkan (the brain), kapkan scrub
and now kapkan edge. Installing it end to end is the
installation guide; the zones file is the zones reference.
How it works
client ──TLS──▶ nginx / Angie (terminates, proxies to your origins)
│ auth_request │ access log (JSON, unix socket)
▼ ▼
kapkan edge ── decision service · clearance page · rollups · ACME · renderer
▲ long-poll of the zone document, self-reports
kapkan (brain) — zones.yaml, the lever, issuance coordinator, console
- The brain distributes policy, never verdicts.
kapkan edgelong-polls one document — the zones with their origins, TLS floor, ACME directories and per-request policy, the rung's signing keys and any lever an operator has pulled — and keeps a copy on disk. Every per-request decision is made on the node; a brain that is down or unreachable changes nothing about how requests are answered. - The renderer turns the document into nginx configuration under the node's state
directory: one shared file (a JSON
log_format, the upstreams for Kapkan's unix sockets, a catch-all that refuses unknown hosts) and one file per zone (the:80listener that answers ACME challenges and redirects, the:443server that proxies to the origins behind anauth_requestto the decision service, and the clearance page's endpoints). Each generation is tested withnginx -tbefore it is installed and reloaded; a failing candidate is never installed — the previous configuration keeps serving and the failure is reported. - The decision service answers nginx's
auth_requestover a unix socket in microseconds with one of three words:200(with an optionalX-Kapkan-Markheader the origin receives),401— clear the rung first, so nginx serves the clearance page in place of the origin — or403. It enforces the zone'spolicy.rateper source — an IPv4 address, or an IPv6/64— with a token bucket for requests per second and an approximate in-flight count for concurrency, and keeps a bounded table of verdicts (deny, challenge or mark, each with a TTL; a deny outranks a challenge, a challenge a mark). A client over its ceiling is answered429withRetry-After; a client the table refuses gets403; a client the table challenges gets the page. - The rollups read the terminator's access log (JSON over a unix datagram socket) into
per-zone, per-source windows of ten seconds. A source that keeps pushing through its rate
ceiling is promoted: in a zone whose rung is
offstraight to a deny (one minute, doubling to ten on repeat); in anautozone to the rung first — the ladder. A source whose requests are almost all errors is markederrorsfor the origin to see. Verdicts stay on the node; the rollups also feed the node's report, so the brain and the console see who was refused or asked, and who would have been. - ACME runs on each node: account keys and certificates are generated on the box and never leave it. The brain only serialises issuance per zone (one node orders at a time) and fans a pending HTTP-01 challenge out to every node that serves the zone, so the CA's validation may land on any of them. Both are advisory: a node renews with the brain gone.
The fast/slow split
Changing a zone's rate, its rung (policy.challenge and its options), a verdict or
pulling the lever never reloads nginx; those flow to the decision service and take effect on the
next request. Only slow changes — a zone added or removed, an origin changed, the TLS floor,
a certificate issued or renewed — produce a new configuration generation, tested and reloaded.
This is what lets a rate be tightened or a zone put under challenge in the middle of an attack
without touching the terminator: the clearance machinery is rendered for every deciding zone,
whatever its rung says, so the bytes are the same for off, manual and auto.
The proof-of-work rung
Between let it through and block it the edge has a middle word: prove you are a browser. A challenged client is served a small page in place of the origin; the page's script solves a hashcash puzzle (a few seconds of CPU at the default difficulty) and posts the answer, which earns a clearance — a signed cookie bound to this zone and this client's source — that lets its requests through the rung for half an hour. A browser barely notices; a script that cannot run JavaScript takes the timed path (below); a bot that only fires requests never gets past it. The rung is not a WAF and not a bot detector: it reads nothing of the request but the path prefix, and only to exempt it; it makes a request cost something, and a client with CPU to spend can clear it — and is then rate-limited like everyone else.
A zone's rung is set by policy.challenge in the zones file: off (the
default), manual — every request without a valid clearance is challenged, the switch for
a site under attack — or auto — nobody is challenged until the node's rollups or an
operator name a source or the whole zone. Both words are watch-only until you say otherwise:
challenge_options.dry_run is true by default, so the rung first shows who it would ask
(the mark would-challenge:<why>, the report's would-be set, the console) and asks nobody.
The ladder
In an auto zone the rollups' flood rule challenges before it denies. A source that pushes
through its rate ceiling for a window is sent to the rung for five minutes (table:flood) — a
browser clears it and is rate-limited like anyone; a bot cannot. A source that already had the
rung's chance — flooding on while challenged (by name, or with the whole zone), having cleared
the rung and flooding anyway, or remembered from an earlier promotion — is denied, one
minute doubling to ten on repeat. In the verdict table challenges may fill at most half of the
room, so a rotating botnet's challenges can never crowd out the denies that must follow; a
flooder whose challenge finds no room is denied instead.
The zone-wide trigger is for the flood no single source trips — residential proxies, a
botnet spread thin: challenge_options.auto.zone_rps (per node; 0 = off) flips the whole zone
to challenge for auto.hold_seconds (default five minutes) when the zone's admitted rate on
the node — decided requests the node did not refuse — runs at or over it; every window still
over extends the hold, and it lapses on its own. Refused traffic is not load: a blocked bot's
403s or a lone flooder's 429s never flip a zone or keep it flipped. The trigger is node-local
by design — each node measures its own window; the fleet-wide view is the brain's report.
The clearance page
On a 401 from the decision service the terminator serves, in place of the origin, a 403
Cache-Control: no-store HTML page from the node's own clearance service (the fourth unix
socket, edge-clearance.sock): the puzzle as a data block, one script and one stylesheet by
content hash, a strict CSP, no images, no third parties, in the visitor's language (en, ru,
de, fr, es from Accept-Language). A non-GET original — an API call, a form post — gets
the compact {"error":"challenge_required"} instead. The script solves the puzzle in lanes (a
few Workers plus a time-sliced lane on the main thread, so a background tab's timer throttling
cannot stall it) and posts the answer to /_kapkan/clearance/answer; a correct one is answered
303 back to the request's own path with
Set-Cookie: kapkan_clr=…; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=1800
— host-only, so a sibling zone can never read it, and bound to the zone and the client's source
key, so it is useless from another address or on another zone. The cookie's value is the one
client value the decision subrequest carries; it is verified against the document's per-zone
keys (derived from a fleet master the brain rotates at UTC midnight, the previous day's honoured
for 48 hours, persisted with edge.state_file so a brain restart does not make every cleared
visitor solve again) — and, last, against a key of the node's own, so a node cut off from the
brain still challenges and clears instead of walling everyone out. A cleared request reaches the
origin marked cleared; the rate and concurrency ceilings still apply to it, so a flood without
cookies is answered with 429s, not with a page per request.
No JavaScript is a first-class path. The page carries a timed ticket, redeemable four
seconds to two minutes after issue, both as a <noscript> meta refresh and as a Continue
button that stays in place unless the script hides it as its first act — JavaScript off, a
blocked or broken script and an engine that fails the solver's self-check all leave the button
there, and it comes back beside a solve that runs long. The ticket earns the shorter
five-minute clearance (cleared:nojs). Every refusal a browser can meet is a page with the
way forward: a too-early ticket retries itself, an expired one and a stale or wrong answer lead
back to the page the visitor came from, the issuance cap says to wait a minute. Clearances are
capped at 6 per source and 6 000 per zone a minute (429 beyond). Accessibility is a review
gate for the page: semantic HTML, one status line announced once, a non-timed alternative to
every timer, both colour schemes at ≥ 13:1 text contrast, focus outlines, reduced motion
honoured.
Two knobs per zone: challenge_options.difficulty (12..22, default 18; each step doubles the
work, and a slow phone must still finish inside the puzzle's two-minute window) and
cookie_ttl_seconds (60..86400, default 1800). challenge_options.exempt_paths names path
prefixes the rung never challenges — health checks, API clients, webhooks. Should the clearance
service itself be down, the 401 follows the zone's failure_mode like any other decision
failure: open passes the request undecided, closed answers 503.
The lever
An operator can set a zone's rung for a bounded time, whatever the zones file says:
curl -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
https://kapkan.example.net:8443/api/v1/edge/zones/shop.example.com/challenge \
-d '{"mode":"manual","ttl_seconds":1800,"reason":"credential stuffing from residential proxies"}'
mode is manual, auto or off (which clears it, like DELETE); ttl_seconds is
60..86400. The override travels in the zone document — parked polls are woken at once — and
every node applies the effective mode on its fast path, reading it per decision, so it ends on
time, brain or no brain, and a zone-wide flip it made inert is retired with it. The response
says where the lever bites: the file's own mode, the zone's watch-only flags (policy.dry_run
and challenge_options.dry_run — on either, the lever previews rather than bites) and every
configured node with its liveness and reported dry_run — a node that only counts must be seen
before the lever is trusted. It is an operator's action (operator rank; a
tenant-scoped operator may pull it on its own zones only — any other zone is
404 unknown zone for it), audited as edge_challenge, named override by GET /api/v1/edge/zones/status
— the console's Edge view shows the mode the nodes apply under it (manual, auto), not the
lever itself — and held in the brain's memory like the issuance coordinator: an incident's tool,
gone after a brain restart (the nodes drop it with the first document the restarted brain serves;
pull it again). The brain's own dry_run does not gate it:
pulling the lever is a policy edit; watch-only lives on the node and the zone. A node running a
version from before the lever does not know the override and follows its file, while the
response still lists it — upgrade the nodes before relying on it (the inventory shows each
node's version).
Watching before it bites
Like every remote role, an edge node defaults to dry-run, and the edge has three layers of it, each a floor the layer below cannot undo:
| Layer | Where | Covers |
|---|---|---|
The node's dry_run (edge.yaml, default true) | the box owner | every decision of every zone on this node |
The zone's policy.dry_run (default false) | the zones file | every decision of this zone, on every node — a zone can only be more watch-only than its node |
The rung's challenge_options.dry_run (default true) | the zones file | the rung alone: challenges are previewed, the ceiling and the block enforce as the layers above say |
Under the first two a would-be denial reaches the origin as an allow marked
X-Kapkan-Mark: would-deny:rate (or would-deny:concurrency, would-deny:table:flood); under
any of the three a would-be challenge reaches it as would-challenge:manual,
would-challenge:zone:<reason> or would-challenge:table:flood — so you can grep your origin's logs, watch
kapkan_edge_decisions_total{result="would_deny"|"would_challenge"} and read who would have
been challenged before a single client is asked: every node reports its zones' last window
with the busiest sources and the strongest thing it did — or would have done — to each, the brain
merges them into GET /api/v1/edge/zones/status, and the console's Edge view shows the
would-be set per zone. Go live one layer at a time: dry_run: false on the node when the
would-deny counts look right, challenge_options.dry_run: false on a zone when its would-be set
names the right clients.
HTTP/3 (QUIC)
A zone with tls.h3: true in the zones file is served over HTTP/3 — QUIC on
UDP 443 — beside TLS-over-TCP, on every node whose nginx or Angie can do it. Nothing changes in
what Kapkan decides: the same auth_request, the same decision service, the same rung and
ceilings over h3 as over TCP; the access log gains a proto field (HTTP/3.0) so the rollups
and kapkan_edge_requests_total{protocol="h3"} show how much of a zone's traffic moved.
Where it renders, and where it honestly does not. At start the node asks its binary
nginx -V and reports a readiness in terminator.h3.state (the inventory, /healthz,
kapkan edge -check):
| State | Meaning | What a zone with h3: true gets on this node |
|---|---|---|
ready | The build carries --with-http_v3_module and edge.yaml has quic.h3: auto (the default) | listen 443 quic and an Alt-Svc announcement |
no_module | The build has no HTTP/3 module (Debian 12's nginx 1.22, for one) | TLS over TCP only; the zone is listed under terminator.h3.unsupported |
node_off | edge.yaml says quic.h3: off | the same |
unknown | The probe failed; the node never guesses about a binary it could not ask | the same |
A zone is never held hostage to one node's package: the document is accepted everywhere, the
nodes that can serve h3 do, the others say so, and GET /api/v1/edge/zones/status shows the
zone's h3 {enabled, serving, unsupported, requests} across the fleet (the console's Edge view
has a cell for it). Turning h3 on or off is a slow-path change — a new tested generation,
a reload on every node — like an origin or a certificate; every fast-path knob (rates, the rung,
the lever, watch-only) still changes no rendered byte on an h3 zone.
What is node-wide, and why. nginx binds a QUIC connection to the address's default server before SNI names a zone, so three things cannot be per zone and Kapkan does not pretend they are:
- Retry is always on (
quic_retry, at thehttplevel of the shared file;quic.retry: falseinedge.yamlturns it off node-wide). A new client address proves it can receive before the handshake costs the node anything, and a Retry is smaller than the Initial it answers, so a spoofed source amplifies nothing. The price is one round trip on a new, unverified connection. There is no "Retry under load": load protection is the data plane's Initial-rate ceiling, below. - 0-RTT is off, with no knob:
ssl_early_datais address-wide on nginx, and most builds could not use it anyway (OpenSSL ≥ 3.5.1 with nginx ≥ 1.29.1 is the floor — the inventory recordsearly_data_capableper node for the day a policy wants it). Replayable early data in front of a rung that issues clearances is not a trade Kapkan makes for you. - The host key (
state_dir/tls/quic_host.key, minted once per node,0600) keeps Retry and stateless-reset tokens valid across reloads; nginx's default — a fresh random key on every reload — would void them exactly when a node reloads under load, and a client may not accept a second Retry.
Rolling it out. A transport has no watch-only mode, so the honest substitute is control over discovery, in this order:
- Nodes first. Upgrade every node before a zone says
h3: true: a node older than E5 refuses a document that carries the key and stays on its previous generation withconverged: false. Checkterminator.h3.statein the inventory. - UDP 443 and MTU. Open UDP 443 on the box, the security group and the upstream ACL;
terminator.h3.listeningsays the box's half is bound, an externalcurl --http3-onlysays the rest. Announcing h3 while UDP is blocked upstream costs every browser a race and a timeout before it falls back to TCP. QUIC never fragments: a path MTU below 1280 bytes (a GRE/IPIP diversion, an overlay) breaks h3 while TCP survives through MSS clamping — see network integration. - The canary:
tls.h3_options.advertise: falserenders the QUIC listener without theAlt-Svcannouncement, so only clients that already speak HTTP/3 to the name reach it (curl --http3-only), browsers stay on TCP. - A short memory:
advertise: truewithalt_svc_max_age_seconds: 300, so a rollback is forgotten in minutes, then the default (86400). - Rollback is
h3: false— one tested generation per node; clients that remembered the announcement fall back on their next connection and mark h3 broken for the origin for a while.
Builds and advisories. nginx still calls HTTP/3 experimental, and the module has had
security advisories of its own. The probe compares the build's nginx core with the published
ranges and reports an advisory — advice, not a verdict: distributions backport fixes without
moving the version, so a 1.26.3 may or may not carry the fix, and only the package changelog
knows. CVE-2026-40460 (cores 1.25.0–1.30.0) matters to Kapkan specifically: after a QUIC
connection migrates, new streams carry an unverified client address, and that address is the
accounting key every decision is made on. Debian 13's 1.26.3-3+deb13u7 carries the fix and
still reports 1.26.3; nginx.org's stable 1.30.4 and Angie 1.12.1 (core 1.31.2) are past the
ranges. Read the advisory before h3: true, or set quic.h3: off on the node until you have.
The two levers, on the data plane. Both live in the XDP data plane in front of the node (or on the same box), never in nginx — a reload at attack speed is the one thing the edge charter forbids:
# kapkan.yaml on the data-plane box
dataplane:
ratelimit_profiles:
- { name: quic_handshake_cap, pps: 20 }
static_rules:
- name: cap_quic # per-source ceiling on QUIC Initials
match: { proto: udp, dst_port: 443, payload: quic_initial }
action: ratelimit
profile: quic_handshake_cap
# - name: kill_quic # the "kill QUIC" lever: clients fall back to TCP
# match: { proto: udp, dst_port: 443 }
# action: drop
Both hot-reload (kapkan -s reload) and preview under the data plane's dry_run; the cap is
the handshake ceiling, the kill rule drops
UDP 443 outright and every client is back on TCP within a round trip — the incident answer to a
QUIC-only problem. A PoP without a Kapkan data plane in front has only the slow lever, h3: false in the zones file, or the host firewall.
What nginx cannot do, and the topologies Kapkan supports. nginx routes a QUIC connection by the socket it arrived on, not by connection ID, so:
- Supported: one node per address; several nodes behind a 4-tuple ECMP or anycast hash — a client whose address changes (Wi-Fi to LTE, a NAT rebinding) lands on a node that does not know the connection and reconnects; nothing breaks, per-node host keys are enough; a Kapkan data plane in front of the node or on it for the cap and the kill.
- Not supported: connection-ID-aware steering and connection migration between nodes
(
quic_bpf on, an operator's own line in nginx'smaincontext, only carries a connection between the workers of one box); TLS session resumption between nodes — nginx binds a session to the node's certificate through the session id context, and Kapkan's certificates are per node, so a client that changes node does a full handshake (rare under a 4-tuple hash; shared ticket keys were investigated and do not help here); WebSocket over HTTP/3 (nginx does not implement RFC 9220 — it stays on TCP); 0-RTT.
Two things to keep in mind: UDP flows need conntrack timeouts a firewall may not give them by
default, and quic_gso (the http context) is yours to set in nginx.conf if you want it —
Kapkan renders neither.
Placing zones on nodes
By default every node serves every zone: one document, one fleet. A fleet with PoPs — a US site and an EU site, say — places zones on nodes along the axis Kapkan already has for prefixes, the hostgroup:
# kapkan.yaml
hostgroups:
- { name: edge-us, networks: ["198.51.100.0/26"] } # the US PoP's VIP prefix
- { name: edge-eu, networks: ["198.51.100.64/26"] } # the EU PoP's VIP prefix
edge:
nodes:
- { name: us-1, hostgroups: [edge-us] } # US zones only
- { name: eu-1, hostgroups: [edge-eu, global] } # EU zones and the global ones
- { name: any-1 } # no scope = the global group alone
# zones.yaml
zones:
- { name: shop.example.com, hostgroup: edge-us, origins: ["10.0.0.10:8080"] }
- { name: shop.example.eu, hostgroup: edge-eu, origins: ["10.0.1.10:8080"] }
- { name: www.example.com, origins: ["10.0.2.10:8080"] } # global
The rule is one sentence: a node serves a zone when the zone's hostgroup is in the node's
scope. A zone's hostgroup is zones[].hostgroup, or global when it names none; a node's
scope is edge.nodes[].hostgroups, or [global] when it lists none. Consequences, in order
of how often they bite:
- A label by itself is strict. Putting
hostgroup: edge-uson a zone takes it off every node that does not listedge-us—any-1above stops serving it. Isolation and the CA's duplicate-certificate budget follow the placement by default; nothing is opened by accident. A zone placed on a group of N nodes is N orders per issuance, not the fleet's — the ceiling in Limits is counted per placement. globalis a literal. A node that must serve its PoP's zones and the fleet-wide ones lists both:[edge-eu, global]. A node with only[edge-eu]gets no global zone.- The scope follows the addressing. A hostgroup needs at least one prefix inside
networks— the PoP's VIP prefix, which you protect anyway. Placement is not a second topology; it is the one you already declared. - A zone's address follows its placement — you move it. Kapkan renders configuration; it
never steers traffic. Placing a zone on
edge-usonly reaches clients once the name resolves to the nodes that listedge-us, and the node that no longer serves it stops answering for it the moment the new document lands: the name falls through to the kapkan catch-all, which closes the connection on:80(nginx'sreturn 444) and refuses the handshake on:443. A request that arrives at the wrong node is a dropped connection, not a404or a stale page — an empty reply or a refused handshake at the client, so read those as wrong node, not as a broken origin. A certificate authority is such a client. It resolves the zone the way the world does, so an HTTP-01 validation that lands outside the placement fails as a connection error — move the DNS record (or the anycast prefix) in the same change that moves the zone, and the node that gains it orders on its first poll. - Each node gets its own document — exactly the zones its scope covers, with their
certificate grants, fanned-out ACME challenges, clearance keys and levers, and its own ETag.
A node asking for the issuance slot of, or publishing a challenge for, a zone it does not
serve gets the same
404 unknown zonea nonexistent zone gets. The placement itself never enters the document. - Scoping requires bound tokens. The moment any node has a scope, every
agenttoken must carrynode— a scope enforced against a shared token would be a promise nothing keeps. Fleets without scopes keep their grace. - Ownership and placement are two axes.
tenantsays who may see the zone,hostgroupwhere it is served; when both the zone and its hostgroup carry a tenant they must agree, and nothing is inherited — a zone in a labelled group is still a house zone unless it says otherwise. The rule is for named hostgroups: the global group is the fleet's catch-all, not a tenant's PoP, so a labelled zone may stay global whateverkapkan.yaml's top-leveltenantsays. A hostgroup that does not exist fails the reload (the previous zones stay live), like any broken zones file. - Never at attack speed. Placement is a slow change like origins or TLS: moving a zone re-renders the nodes it leaves and joins and reloads their terminators. The fast/slow split is untouched.
kapkan -check-config prints the matrix — each node's scope, how many zones it covers, its
token — and warns about a zone no node's scope covers (it is served nowhere until a node lists
its group). GET /api/v1/edge/zones/status carries each zone's placement {hostgroup, nodes, alive} and flags unserved when its nodes exist but none is alive; the lever's response lists
the zone's nodes, not the fleet's; the inventory shows each node's hostgroups and
zones_placed. A node's claims about a zone outside its scope are stored in its report but not
merged into the zone's status.
Runbook — a zone leaves a node. Moving a zone off a node is an ordinary slow reload there: the terminator stops serving the name, the node's certificates for it stay on disk and stop renewing. Delete them when you are sure, or leave them; nothing renews a certificate for a zone the node no longer serves.
What the node does when things fail
| Failure | Behaviour |
|---|---|
| Brain down or unreachable | The node keeps serving the last document and certificates from disk; renewals continue; a reboot comes back into service without the brain (fail-static). A lever in the cached document still ends at its until; the rung's keys stay valid for 48 hours and the node's own key takes over after that. Under a shared or anycast address a renewal's HTTP-01 validation may land on another node while the brain is down; such attempts retry hourly and never count toward the fallback CA. |
| Decision service down or slow | Per zone: failure_mode: open (the default) passes the request undecided and counts it; closed answers 503. |
| Clearance page down | A 401 that cannot be turned into the page follows the zone's failure_mode the same way: open passes the request, closed answers 503. |
Document refused (renderer, or nginx -t fails) | The previous generation keeps serving; the node stays healthy but converged: false, reports the rendered document's ETag (not the accepted one), and retries locally every 1 → 10 minutes until it applies or a newer document arrives. The fast path — rates, rungs, the lever — is applied from the accepted document meanwhile. |
| nginx dies | systemd restarts it; with terminator.pid_file set the node reports alive: false and answers 503 on its /healthz meanwhile. Kapkan never supervises the terminator itself. |
| Certificate unrenewable | The current certificate serves until it expires; kapkan_edge_cert_not_after_seconds is the T−30 d alarm; after three failures the node tries the fallback CA. |
| Node dies | The brain sees the poll stop (edge.stale_after_seconds) and shows it in the inventory; steering traffic away is your network's job (anycast withdraw, DNS). |
Reading the node
/healthzonstatus_listenanswers200while a tested generation of the node's own is live (and the terminator is alive, when a pid file is configured),503otherwise. The body carriesconverged, the rendered and accepted ETags, the live generation and its test result, the last error, the next retry, when the brain was last seen, anddry_run./metricson the same address exposes the node's Prometheus series — decisions by result (challenge,would_challenge,allow_clearedamong them), the clearance page's outcomes, whether a zone-wide challenge is on — see metrics.- The brain's inventory,
GET /api/v1/edge/nodes, joins each configured node's liveness — the zones poll, and nothing else — with its last self-report: version, dry-run, rendered ETag, terminator kind and version, its HTTP/3 readiness (terminator.h3.state:ready,no_module,node_off,unknown, with an advisory when the build's nginx core falls in a published QUIC advisory's range), generation and test result, certificates (never a key), and the zones section — each zone's last ten-second window, the sources it refused, asked or would have, and whether its rung bites or previews there. A sighting is stamped when a poll starts and again when it ends, so while a poll is parkedlast_seenholds that poll's start and a healthy node's sighting is routinely older thanedge.stale_after_seconds;holdingis what says a poll is open right now, and a parked node is alive whateverlast_seensays. See the API. - The zones across the fleet,
GET /api/v1/edge/zones/status, lists every zone of the zones file and merges the alive nodes' reports into it (a tenant-scoped token gets its own zones): rates summed, the nodes on which a zone is watch-only or under a zone-wide challenge (biting or previewing), the lever in force, and the would-be set. The console's Edge view — shown onceedge.nodes[]is configured — renders it; see the dashboard.
Limits to know
- TLS floor on older nginx. nginx before 1.29.2 applies the default server's
ssl_protocolsto every name, so the node's catch-all carries the lowesttls.min_versionof all its zones and every zone gets that floor. Angie and nginx ≥ 1.29.2 honour the per-zone floor. Where this matters, keep every zone on the same floor or run Angie. - One default server. Kapkan renders its own catch-all
default_serveron:80and:443that refuses unknown hosts and SNI. If yournginx.confalready declares one (Debian's default site does),nginx -tfails with a duplicate default server — setomit_catch_all: trueand let your servers take that role. - Origins resolve at test time. A hostname origin is resolved once, when nginx loads the
configuration; one that does not resolve fails
nginx -tfor the whole generation (and is retried). Prefer addresses. - The rung is a cost, not a verdict. A client with CPU to spend clears it (and is then rate-limited like anyone); a client behind a shared address — an office, a carrier NAT — shares its source key with its neighbours, and the six-clearances-a-minute cap per source is theirs together. The rung never reads a request beyond the path prefix it may exempt: it is not a WAF and does not try to be one.
- The zone-wide trigger is per node, and the lever lives in the brain's memory: a brain
restart forgets it, and the nodes drop it with the first document the restarted brain serves —
pull it again. A brain that stays down is different: the cached document's lever ends at its
until(see the failure table). - Wildcards are a later milestone. Every name is listed explicitly.
- HTTP/3 is per zone, rendered where the node can, node-wide in what nginx decides before SNI (Retry on, 0-RTT off), with no connection migration or session resumption between nodes — see HTTP/3 (QUIC).
- Duplicate-certificate ceilings. Per-node certificates mean the N nodes a zone is
placed on are N orders (the whole fleet for a global zone); Let's
Encrypt caps a name at about five per week. The brain's issuance slot and
renewal jitter spread them;
acme.fallback(ZeroSSL, Google Trust Services — both need an External Account Binding) is the escape hatch. Plan CA accounts for large fleets. - The agent token issues certificates — for its node's zones. Bind each agent token to its
node (
api.tokens[].node, see authentication) so a stolen token cannot act as another node, and place zones on nodes so its node serves only the zones it should: a stolen bound token can then take the issuance slot and publish a challenge answer only for the zones its node's scope covers (every call logged), and a node compromise is a certificate exposure for those zones, not the fleet's. Rotate that node's token; the brain holds nothing to steal.
Related
- Install an edge node — brain side, nginx side, the unit, going live
- Zones reference — every key of
zones.yaml, and what a policy does to a request kapkan edge— the command, its flags andedge.yaml- The edge channel — the brain's routes, the zone status and the lever
- Metrics — the
kapkan_edge_*series - Dashboard — the console's Edge view
- Fingerprint plane — blocking clients off-path by JA4, before the terminator