GitHub

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

FlagDefaultDescription
-config <path>configs/dev.yamlPath to the YAML configuration file.
-log-format <fmt>jsonLog 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>infoMinimum 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-schemafalsePrint the configuration JSON schema to stdout and exit. See below.
-versionfalsePrint the build version and exit. The same version is exposed at runtime via /api/v1/status and the kapkan_build_info metric.
-check-updatefalseRun 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.pidWhere 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 valueSignalEffect
reloadSIGHUPHot-reload the config file. Same as systemctl reload kapkan or POST /api/v1/config/reload.
stop / quitSIGTERMGraceful 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 codeMeaning
0The config is valid.
1The 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:

  • GENERIC attach mode is called out on the first line. xdp_mode: auto falls 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.
FlagDefaultDescription
-jsonfalsePrint the whole inspection as JSON, including the state field the exit codes are derived from.
-pin-path <dir>from -configThe 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 codeReported stateMeaning and what to do
0enforcingAt least one interface has a live XDP attachment. Nothing to do.
10no_pin_path, no_program, detachedThe 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.
11not_bpffs, torn, schema_skewSomething 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).
1attach_unknown, or any read failureThe command could not answer — most often permission (see below). Deliberately not reported as "not filtering", because it may well be filtering.
2Usage 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 want sudo kapkan dataplane status.
  • On a kernel with kernel.unprivileged_bpf_disabled set — the default on Debian and Ubuntu — bpf(2) additionally needs CAP_BPF, even to open an existing pin.
  • Link pins need write permission, not just read: the kernel refuses BPF_OBJ_GET on a bpf_link unless the descriptor is opened O_RDWR. A non-root reader therefore sees every map and no attachment, which is reported as attach_unknown (exit 1) 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.

FlagDefaultDescription
-config <path>/etc/kapkan/scrub.yamlThe scrub-node config.
-log-format <fmt>jsonjson or text.
-log-level <lvl>infodebug, 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