Rate limits
The Panel limits the two things an unauthenticated caller can reach, and limits login along a second axis that needs no address at all:
| what | keyed on | rate | burst |
|---|---|---|---|
| download-token redemption | client address | 30/min | 10 |
POST /auth/login |
client address | 20/min | 20 |
| login + change-password failures | submitted username | 10/min | 10 |
A refusal is a 429 in the ordinary JSON error envelope with Retry-After,
and a refused request does not consume future capacity.
The download limit sits on the token branch, not on the route: a session-authenticated download is not what the limit is for, and an operator must not be refused for sharing a NAT address with somebody probing tokens. The login numbers are deliberately generous for the same reason — locking a team out of their own Panel is a worse failure than the guessing being slowed.
Keys are per client, and the client is resolved once. IPv4 is keyed per address; IPv6 is aggregated to the /64, because a single subscriber is routinely delegated a whole /64 and per-address keying would hand one attacker 2^64 independent buckets. The table is swept for idle entries and, at its cap, evicted down to a watermark; entries that cannot make a request right now are passed over, so filling the table is not a way to buy back a spent bucket.
"Per client" means whatever clientIP
resolves to. Behind a proxy with no KRAKEN_TRUSTED_PROXIES set, that is the
proxy, for everybody, and the per-address limiters become one shared bucket a
stranger can exhaust for the whole fleet. Of the ways to misconfigure a Panel,
it is the one I would check first.
The per-username login limiter
The address-keyed limiter has a topology it cannot survive. On Docker Desktop
every connection to a published port arrives rewritten to the VM gateway
192.168.65.1 — including the connections a reverse proxy on the same host
publishes — so the Panel resolves the entire internet to one address. The login
bucket is then twenty attempts a minute for everybody at once: protective of
nobody, and a self-inflicted lockout the first time a stranger finds the login
page. Behind a reverse proxy has the full
story and the fix.
So login is limited on a second key that owes nothing to the network: the submitted username, normalised (trimmed, lower-cased), at ten a minute with a burst of ten. Three things about it are worth knowing:
- It counts failures only. A correct password spends nothing. Somebody guessing at your username cannot keep you out of your own Panel by exhausting a budget you never touch — they can only keep themselves out, which is the point.
- It applies to every submitted username, whether or not the account exists. A 429 that came back only for real accounts would answer "does this user exist?" for anybody patient enough to ask eleven times.
POST /auth/change-passwordspends the same budget, keyed on the authenticated user, when the current password is wrong. That route verifies a password exactly as login does, so leaving it outside would make a stolen session the way around the login limiter. The consequence is worth stating plainly: fumble your current password ten times on the change-password screen and your own sign-in is refused for the rest of that minute.
A brute-force against one account is therefore slowed even when the Panel has no idea who is calling, and every other account carries on unaffected.
exempting an address the per-IP limit cannot help
KRAKEN_RATE_LIMIT_IP_SKIP=192.168.65.1A comma-separated list of CIDRs (bare IPs allowed) whose resolved client address is exempt from the per-address limiters. It exists for exactly the situation above: an address that stands in for everybody is not a client, and limiting it refuses the whole internet together rather than refusing anyone in particular.
It does not exempt anything from the per-username limiter, which is what
makes it safe to set: password guessing is still capped at ten a minute per
account. An unparseable entry is a startup error, the same as
KRAKEN_TRUSTED_PROXIES.
Reach for the shared-Docker-network fix first. This is for when you cannot.
the off switch
KRAKEN_RATE_LIMITS=all # the default; "on" is accepted as its old spelling
KRAKEN_RATE_LIMITS=login # the login limiters only
KRAKEN_RATE_LIMITS=downloads # the download-token limiter only
KRAKEN_RATE_LIMITS=off # neitherAnything else is a startup error: every value here is a deliberate posture,
and a typo must not be read as one of them. Anything other than all is logged
loudly at startup.
The switch is split because the two surfaces fail differently. The advice for a
Panel behind a NAT that erases the client used to be off, which threw away the
token-redemption limiter as well — a limiter that has no such problem, since it
guards a credential nobody shares.
Body caps
Not configurable, listed here because the answers arrive as HTTP errors:
- Every JSON request body is capped at 4 MiB. Sized off the largest
legitimate body, which is the in-browser editor saving a 1 MiB file,
JSON-escaped. Without a cap, any authenticated caller could pin arbitrary
Panel memory with one request, because
json.Decoderreads until the body ends. - Uploads are capped at 64 MiB plus a megabyte of multipart framing, and
answer 413 past it.
ParseMultipartForm's argument is only the in-memory threshold — everything past it spills to temp files, unbounded — so without this one authenticated request could fill the Panel's disk. Anything larger belongs on SFTP. - A download token's path set is capped at 256 paths of 4096 bytes, since a mint holds that set in memory for 60 seconds.
Log level
KRAKEN_LOG_LEVEL=debug # debug | info (default) | warn | errorAn unrecognised value falls back to info: a typo must not silence the Panel.
Several diagnostics are Debug on purpose, because they are writable by an
unauthenticated caller. A rejected download token is the example. Turn the level
down when you are diagnosing something; leaving it there hands a stranger a
write into the log an operator actually reads.
Logs are JSON. Read them wherever the Panel runs:
docker compose --env-file deploy/.env -f deploy/docker-compose.full.yml logs -f panel
journalctl -u kraken-panel -fThe Agent keeps its own log: journalctl -u kraken-agent on Linux,
C:\kraken\state\agent.log on Windows (JSON, rotated at 10 MiB).
Audit retention
The audit log is the one thing the Panel keeps in Postgres rather than on stdout, so it has a window of its own:
KRAKEN_AUDIT_RETENTION_DAYS=90 # 0 keeps every entry foreverA daily job deletes entries past the window in batches. The value must be a
whole number of days, 0 or more; anything else stops startup. The mechanics,
and what the console shows for each setting, are on the audit
log.
Metrics
The Panel exports Prometheus metrics at GET /metrics, unauthenticated
alongside /healthz and /readyz. It belongs on a network your scrapers can
reach and strangers cannot, which is where the rest of the Panel belongs too.
| metric | what |
|---|---|
kraken_build_info{version} |
the running Panel's version |
kraken_servers{state} |
servers by lifecycle state |
kraken_nodes_total, kraken_nodes_online |
fleet size and health |
kraken_schedules_total |
cron schedules |
kraken_http_requests_total |
request counter |
kraken_audit_events_total |
audit rows written |
kraken_download_tokens_rejected_total |
refused download-token redemptions |
kraken_rate_limited_total |
refusals, per limiter |
kraken_rate_limited_total carries a limiter label: download, login (the
per-address one) and login_user (the per-username one). A spike on
login_user with nothing on login is somebody working through one account
from many addresses — or from behind a NAT, where login cannot see them apart
in the first place.
The last two are why the quiet Debug lines are also counted: they give an
operator the signal without turning the log level down. A 429'd login never
reaches the handler that audits a failed attempt, so the limiter writes that row
itself, once per key per window rather than once per request.