CLI reference
Kapkan is a single binary. What it does is selected by command-line flags at startup, plus a
small number of commands that inspect the running system and exit. All logs go to
stderr; the REST API and Prometheus metrics are served over HTTP on api.listen (default
127.0.0.1:8080), while the utility flags (-version, -dump-schema) and the commands print
their output to stdout.
kapkan [flags] # run the daemon
kapkan [flags] <command> [args] # run a command and exit
Global flags come before the command; the command's own flags come after it. So
kapkan -config /etc/kapkan/config.yaml dataplane status -json reads the config path from the
global -config and passes -json to dataplane status. A flag that already exits on its own
(-version, -check-config, -dump-schema, -check-update, -s) cannot be combined with a
command — Kapkan refuses the combination rather than silently honouring one of them.
Flags
| Flag | Default | Description |
|---|---|---|
-config <path> | configs/dev.yaml | Path to the YAML configuration file. |
-log-format <fmt> | json | Log output format: json (structured, for log collectors) or text (human-readable, for local runs). Any value other than text is treated as json. |
-log-level <lvl> | info | Minimum log level: debug, info, warn, error. An unrecognized value falls back to info. |
-check-config <path> | — | Validate the config file at <path> and exit — does not start the daemon. See below. |
-dump-schema | false | Print the configuration JSON schema to stdout and exit. See below. |
-version | false | Print the build version and exit. The same version is exposed at runtime via /api/v1/status and the kapkan_build_info metric. |
-check-update | false | Run the opt-in update check once and exit — 0 up to date, 10 an update is available, 1 on error. Polls the GitHub Releases API; sends only the request itself, never node identity or config. |
-pid-file <path> | /run/kapkan/kapkan.pid | Where the daemon writes its process id on start (removed on clean shutdown); read by -s to locate the running daemon. A write failure is non-fatal — the daemon still runs, only -s is unavailable. |
-s <signal> | — | Signal a running daemon and exit, by reading -pid-file: reload, stop or quit. Does not start a daemon. See below. |
ijson or text?
Use -log-format text for local development — it is the format the
Quickstart uses. Keep the default json in production so a log
collector can parse the structured fields; the systemd unit ships with
-log-format json -log-level info.
Running the daemon
With no utility flag, Kapkan opens its flow listeners, REST API and (when not in dry-run) BGP
speaker, then runs until it receives SIGINT or SIGTERM, at which point it shuts down
cleanly. It exits non-zero only on a fatal startup or runtime error.
# Local, human-readable logs against the bundled dev config:
kapkan -config configs/dev.yaml -log-format text
# Production-style: structured logs at info level:
kapkan -config /etc/kapkan/config.yaml -log-format json -log-level info
The config file is reloadable at runtime without a restart — run kapkan -s reload,
send SIGHUP directly (systemctl reload kapkan), or POST /api/v1/config/reload. See the
configuration reference for what reloads and what is fixed at startup.
Controlling a running daemon
kapkan -s reload # re-read the config file (SIGHUP)
kapkan -s stop # graceful shutdown (SIGTERM)
kapkan -s quit # alias of stop
-s reads the process id from -pid-file (default /run/kapkan/kapkan.pid, written by the
daemon on start) and delivers the matching signal to that process — the same nginx-style local
control as nginx -s reload. It needs no network connection and no API token, so it works even
if the REST API is down; it does need permission to signal the daemon, so run it as root or the
kapkan user.
-s value | Signal | Effect |
|---|---|---|
reload | SIGHUP | Hot-reload the config file. Same as systemctl reload kapkan or POST /api/v1/config/reload. |
stop / quit | SIGTERM | Graceful shutdown. Same as systemctl stop kapkan. The two are aliases — Kapkan's shutdown is always graceful (it asks BGP peers to retain mitigation routes via Graceful Restart). |
If the pid file is missing or stale (no such process), or you lack permission to signal it, -s
prints the reason to stderr and exits 1.
iWhere the pid file lives
The bundled systemd unit sets RuntimeDirectory=kapkan, so /run/kapkan
exists and is writable by the kapkan user; the daemon writes /run/kapkan/kapkan.pid there and
removes it on a clean stop. In a local dev run that directory may not exist — the daemon logs a
warning and continues, and -s will report "no pid file". Pass -pid-file /tmp/kapkan.pid to both
the daemon and -s if you want the shortcut locally.
Utility flags
These flags exit before the daemon starts — they never open a listener or send a route
announcement, so they are safe to run on a production host. (-check-update makes a single
outbound request to the releases API; the others are fully offline.)
Validating a config
kapkan -check-config /etc/kapkan/config.yaml
Runs the engine's exact parse and validation — including cross-field rules a static schema cannot express — then prints the resolved configuration (mode, protected networks, the configured flow listeners, the group count, and the effective mitigation per hostgroup) and exits:
| Exit code | Meaning |
|---|---|
0 | The config is valid. |
1 | The config is invalid; the exact error is printed to stderr. |
The clean 0/1 split drops straight into CI or a pre-deploy gate. See Validating the configuration.
A valid config can still print a WARNING block after the OK line — legal config that the
daemon will run, but almost certainly not what you meant. Today there is one: a
static rule that can never fire, naming the
rule and what takes its packets. The exit code stays 0, so an existing gate is unaffected; if
you want warnings to fail the build too, check the output for WARNING as well as the code.
Dumping the schema
kapkan -dump-schema
Prints the configuration's JSON schema to stdout and exits 0 (or 1 on an internal error).
The config builder consumes this schema to validate fields in your browser.
Printing the version
kapkan -version
Prints the build version (release tag plus short VCS revision when known) to stdout and exits.
The same string is served at runtime on /api/v1/status and carried by the kapkan_build_info
metric — so you can confirm a binary's version locally with zero egress.
Checking for updates
kapkan -check-update
Runs the update_check once on demand and exits: 0 if up to date, 10
if a newer release is available, 1 on error. It queries the GitHub Releases API and sends only
the request itself (your IP and a generic User-Agent) — never node identity, config or attack
data. The periodic in-process check is off by default; this flag works regardless of that
setting.
Commands
dataplane status
kapkan dataplane status # human-readable report
kapkan dataplane status -json # the same inspection as JSON
Reports whether the XDP data plane is actually filtering, and why not when it isn't. Two properties make it usable during an incident:
- It is strictly read-only. It opens the pinned program and maps read-only — the map file
descriptors carry
BPF_F_RDONLY, so the kernel refuses a write through them — and it never loads a program, creates a map, writes a map value, attaches, detaches or removes a pin. Starting the daemon adopts-or-rebuilds a pin set; this command cannot, so it can never cost you the mitigation rules you are trying to diagnose. - It works with the daemon stopped. That is the main case, not a bonus: with the default
dataplane.on_exit: keep, the kernel goes on enforcing static policy with no Kapkan process at all, and this command reads the pins directly to tell you so.
The report leads with the verdict and the remedy, then gives the detail: attached interfaces with the mode actually in force, the kernel and map-schema versions, the live generation, static and dynamic rule counts, per-map size/occupancy/memory, the dry-run flag, and the verdict counters.
Two things in that output are worth knowing about in advance:
GENERICattach mode is called out on the first line.xdp_mode: autofalls back from the driver path to the generic (skb) path without failing, and that costs roughly an order of magnitude of capacity, so it is a headline and not a field among twenty.- Verdict counters are printed in two blocks. The terminal counters partition the traffic —
exactly one is bumped per packet — so they are totalled. The observation counters are bumped
alongside a terminal one for the same packet (
dryrun_would_drop,pass_rule_expired,pass_frag_noports,err_policy_missing); adding them to the total would produce a packet count larger than the number of packets, so they are listed separately and never summed in.
| Flag | Default | Description |
|---|---|---|
-json | false | Print the whole inspection as JSON, including the state field the exit codes are derived from. |
-pin-path <dir> | from -config | The bpffs directory to inspect. Defaults to dataplane.pin_path from the config named by the global -config, falling back to /sys/fs/bpf/kapkan when that file cannot be read. The report always states which path it looked at and where that path came from. |
Exit codes
| Exit code | Reported state | Meaning and what to do |
|---|---|---|
0 | enforcing | At least one interface has a live XDP attachment. Nothing to do. |
10 | no_pin_path, no_program, detached | The data plane is not filtering, and nothing about it is broken — it has never run here, or it is stopped, or its attachments are gone. Start Kapkan, or check that the configured interfaces exist. |
11 | not_bpffs, torn, schema_skew | Something must be fixed before it can work: bpffs is not mounted, the pin set is torn, or the pinned maps are a different schema version than this binary. The printed reason names the fix (mount bpffs; restart Kapkan). |
1 | attach_unknown, or any read failure | The command could not answer — most often permission (see below). Deliberately not reported as "not filtering", because it may well be filtering. |
2 | — | Usage error: an unknown flag, command or stray argument. |
Only enforcing exits 0, so kapkan dataplane status >/dev/null drops straight into a
monitoring check. The 10-for-a-meaningful-state convention is the same one
-check-update uses.
Permissions
Reading pins is much cheaper than creating them: on a kernel with
kernel.unprivileged_bpf_disabled=0, no capability at all is required — neither
CAP_NET_ADMIN nor CAP_PERFMON, which the daemon needs in order to load and attach, is
involved. What does gate it:
- The pin directory is mode
0700, owned by the user the daemon runs as, so in practice you wantsudo kapkan dataplane status. - On a kernel with
kernel.unprivileged_bpf_disabledset — the default on Debian and Ubuntu —bpf(2)additionally needsCAP_BPF, even to open an existing pin. - Link pins need write permission, not just read: the kernel refuses
BPF_OBJ_GETon abpf_linkunless the descriptor is openedO_RDWR. A non-root reader therefore sees every map and no attachment, which is reported asattach_unknown(exit1) rather than as "detached".
iIt never mutates the thing it is diagnosing
This is why dataplane status is a separate read-only path instead of reusing the daemon's own
startup code. Starting the data plane adopts an existing pin set — or, when the schema does not
match, tears it down and rebuilds it, discarding every dynamic mitigation rule in the kernel. A
diagnostic that could do that mid-attack would be worse than no diagnostic.
scrub
kapkan scrub -config /etc/kapkan/scrub.yaml
Runs the scrub-node role: the same binary, a different job. There is no detection, no BGP and no telemetry listener — the box receives traffic the brain diverted to it, long-polls the brain's rule table, and keeps its local XDP data plane enforcing exactly what the brain says to drop. The poll doubles as the node's liveness signal.
Everything role-specific lives in its own scrub.yaml; the daemon's config.yaml is not
read (-config here defaults to /etc/kapkan/scrub.yaml). It has three parts: the controller to
reach, the node's identity, and the same dataplane: block a daemon carries.
dry_run: true # the remote-role default — counts, drops nothing
controller:
url: "https://kapkan.example.net:8443" # the brain's API base (no path)
token_env: KAPKAN_AGENT_TOKEN # env var holding the agent token (required)
name: scrub-fra1 # must equal a scrubbing.nodes[] name on the brain
dataplane:
interfaces: [eth0] # the dirty side (diverted traffic arrives here)
xdp_mode: auto
pin_path: /sys/fs/bpf/kapkan
!A scrub node defaults to dry-run
dry_run defaults to true for a remote role — the node installs the rules and counts what
they would drop, but drops nothing, until you set dry_run: false explicitly. It never
enforces a rule the brain marked dry-run while it is live, and it requires a real agent token:
without one its polls carry no identity and the brain would count it dead. Use https outside a
lab — a plaintext controller.url sends the token in the clear.
| Flag | Default | Description |
|---|---|---|
-config <path> | /etc/kapkan/scrub.yaml | The scrub-node config. |
-log-format <fmt> | json | json or text. |
-log-level <lvl> | info | debug, info, warn or error. |
Confirm a running node with kapkan dataplane status on the node itself — it reports ENFORCING
and the installed rule count — and the brain's Nodes view shows it alive.
Getting the diverted traffic to the node is the network integration guide.
Next steps
- Quickstart — download Kapkan and run it in dry-run.
- Configuration reference — every key in the YAML file.
- Production deployment — the systemd unit and its flags.
- Troubleshooting — symptom-keyed fixes, including the data plane.