Rate limiting
Huginn has two different rate limiters. They do not share knobs, counters, or goals:
Proxy [security.rate_limit] |
eBPF agent HUGINN_EBPF_RATE_LIMIT_* |
|
|---|---|---|
| Layer | HTTP request (after accept / TLS) | TCP SYN capture (XDP/TC datapath) |
| When over limit | 429 response | Fingerprint not captured; packet still reaches the stack |
| Protects | Backends / application abuse | Capture LRU (tcp_syn_map) from one loud source |
| Sized against | Request rate you want to allow | HUGINN_EBPF_SYN_MAP_MAX_ENTRIES (and CPU/queues) |
| Scope | Global / domain / route | Per source IP (IPv6: /64 prefix) |
The rest of this page covers the proxy token bucket. The eBPF limiter and how to run both without losing TCP signatures are in eBPF SYN rate limiter.
Token-bucket limits live under [security.rate_limit] (global), [domains.security.rate_limit] (per domain), and [domains.routes.security.rate_limit] (per route). Dynamic.
Per-domain and per-route blocks are whole-block replaces: the most specific scope that defines a rate_limit block replaces the parent’s block entirely. There is no field-level inheritance.
See examples/config/rate-limit-example.toml on GitHub for a full file.
Strategies
Section titled “Strategies”Key limit_by values:
ip: client IP (resolved fromX-Forwarded-Forwhen[security.trusted_proxies]is set; see below)header: value of a named header (limit_by_header)route: shared limit per route pathcombined: IP + route
Responses use 429 when exceeded. Counters are in-memory and per process; they are not shared across replicas.
Real client IP behind a load balancer
Section titled “Real client IP behind a load balancer”When the proxy sits behind a trusted load balancer, set [security.trusted_proxies] (cidrs + optional insecure) so limit_by = "ip" and "combined" resolve the real client IP from X-Forwarded-For instead of the proxy’s IP:
security: trusted_proxies: cidrs: - "10.0.0.0/8" - "172.16.0.0/12" rate_limit: enabled: true requests_per_second: 500 burst: 1000 limit_by: "ip"[security.trusted_proxies]cidrs = ["10.0.0.0/8", "172.16.0.0/12"]
[security.rate_limit]enabled = truerequests_per_second = 500burst = 1000limit_by = "ip"trusted_proxies is global only: it is a property of the network topology, not of any individual domain or route. See Security.
Global default
Section titled “Global default”security: rate_limit: enabled: true requests_per_second: 1000 burst: 2000 window_seconds: 1 limit_by: "ip"[security.rate_limit]enabled = truerequests_per_second = 1000burst = 2000window_seconds = 1limit_by = "ip"Per-domain override
Section titled “Per-domain override”The domain block replaces the global rate-limit config entirely for all requests that match this domain.
domains: - host: "api.example.com" security: rate_limit: enabled: true requests_per_second: 50 burst: 100 limit_by: "combined"[[domains]]host = "api.example.com"
[domains.security.rate_limit]enabled = truerequests_per_second = 50burst = 100limit_by = "combined"Per-route override
Section titled “Per-route override”The route block replaces the domain (or global) rate-limit config for requests matching this route.
domains: - host: "api.example.com" routes: - prefix: "/api" backend: "backend-a:9000" security: rate_limit: enabled: true requests_per_second: 50 burst: 100 limit_by: "combined" - prefix: "/public" backend: "backend-b:9000" security: rate_limit: enabled: false[[domains]]host = "api.example.com"
[[domains.routes]]prefix = "/api"backend = "backend-a:9000"
[domains.routes.security.rate_limit]enabled = truerequests_per_second = 50burst = 100limit_by = "combined"
[[domains.routes]]prefix = "/public"backend = "backend-b:9000"
[domains.routes.security.rate_limit]enabled = false # disable for this routeRate limit by header
Section titled “Rate limit by header”security: rate_limit: enabled: true requests_per_second: 200 burst: 400 limit_by: "header" limit_by_header: "X-API-Key"[security.rate_limit]enabled = truerequests_per_second = 200burst = 400limit_by = "header"limit_by_header = "X-API-Key"eBPF SYN rate limiter
Section titled “eBPF SYN rate limiter”Optional. Only relevant when the TCP SYN path is on (fingerprint.tcp_enabled + eBPF agent). Configured on the agent with environment variables — not in [security.rate_limit].
| Variable | Default | Role |
|---|---|---|
HUGINN_EBPF_RATE_LIMIT_ENABLED |
false |
Turn the in-kernel limiter on |
HUGINN_EBPF_RATE_LIMIT_BURST |
2000 |
Max SYNs per window per source before skipping capture (1..=65534, per CPU) |
HUGINN_EBPF_RATE_LIMIT_WINDOW_SECONDS |
1 |
Sliding window (1..=3600) |
When a source exceeds BURST, later SYNs from that source are not written to the capture map. The TCP handshake and HTTP path still proceed; you only lose x-tcp-p0f for those connections. This is not a network DoS shield and it is not a substitute for [security.rate_limit].
Size BURST against the capture LRU:
burst ≈ HUGINN_EBPF_SYN_MAP_MAX_ENTRIES / (4 × cpus)Use the NIC’s RX queue count (or CPU count on veth/single-queue). Full tables and caveats: EBPF-SETUP.md — Sizing the SYN rate limiter. The compose default 2000 is a placeholder for a single-core lab host — recompute for production.
Watch agent metrics (rate, not totals — counters are pinned across restarts): tcp_syn_rate_skipped_total should stay flat under normal load; if it climbs without an attack, BURST is too tight (common behind a shared NAT). See Telemetry.
Coordinating proxy and eBPF limiters
Section titled “Coordinating proxy and eBPF limiters”Treat them as orthogonal. Copying the same burst / RPS numbers between them is almost always wrong.
- Decide who stops abuse. HTTP abuse → tighten
[security.rate_limit](429). Capture-map thrash from one loud IP opening many connections → enable and size the eBPF limiter. Do not expect the eBPF limiter to return 429 or protect backends. - Preserve TCP signatures for legitimate clients. Prefer that a healthy client hit the proxy limit (request denied, but the SYN was already captured) rather than the eBPF limit (request may succeed without
x-tcp-p0f). Keep eBPFBURSThigh enough that normal peak SYN rates (including NAT gateways) do not skip capture; keep the proxy request budget as the tighter application control. - New connections vs keep-alive. The eBPF path only sees SYNs. Keep-alive requests reuse the fingerprint from accept time. A client that already has
x-tcp-p0fcan still be HTTP-rate-limited on later requests without losing that header. A client skipped at SYN time never gets the header for that connection. - Validate independently. Proxy: rejection rates on
huginn_rate_limit_*. Agent:tcp_syn_rate_skipped_total/tcp_syn_rate_allowed_total. If skipped climbs while allowed goes flat across many sources, the sketch may be saturated (distributed flood) — that is a capture outage, not “working rate limit”; raise map size / revisit whether the limiter should stay on. - Fail closed on bad agent config. An invalid
HUGINN_EBPF_RATE_LIMIT_*value stops the agent; unset uses defaults. Typos here also leave the proxy without maps until the agent starts.
Env table and Compose layout: eBPF TCP setup.
Related
Section titled “Related”- Security:
trusted_proxies, connection limits, security headers - IP filtering:
[security.ip_filter] - eBPF TCP setup: agent env vars and capture backends
- Routes & Domains: prefix and backend selection
- Configuration overview