A small, demonstrable DDoS mitigation engine. Levee sits in front of a backend as an inline TCP proxy and decides, per connection, whether to drop or forward the traffic. It uses a counting Bloom filter to track known-bad sources and a per-IP leaky bucket for rate limiting. A Scapy sniffer thread reports live traffic stats, and a Flask dashboard makes every decision visible in real time.
client ──► [ Levee :8080 ] ──► [ backend :8080 ]
│
├── Allowlist (trusted IPs → always forward)
├── Counting Bloom (known bad IPs → drop, per-IP TTL)
├── Leaky bucket (per-IP rate → drop on overflow, add to bloom)
├── Reaper thread (expires each block on its own timer)
├── Scapy sniffer (observability: pps, proto mix)
└── Flask dashboard (:5001) + REST API + /metrics
Requires Docker.
./start.sh up
open http://localhost:5001
./start.sh legit # send 5 legitimate requests
RATE=300 DURATION=10 ./start.sh attack # run the flooder
./start.sh logs # tail mitigation logs
./start.sh down # tear everything downThe mitigation container is the public face of the system. Every accepted TCP connection is evaluated by the decision engine, in this order:
- Allowlist. If the source IP is trusted, forward immediately.
- Manual block. If the IP was blocked by hand, drop it.
- Bloom filter. If the IP is in the counting Bloom filter, drop it.
- Leaky bucket. Add one token. If the bucket overflows, add the IP to the Bloom filter (with a TTL) and drop it.
- Otherwise, splice the connection to the backend.
The Bloom filter is a counting Bloom filter: each slot is a small counter
rather than a single bit, which is what makes per-entry removal possible.
Every automatically blocked IP gets its own TTL (BLOCK_TTL_SEC). A reaper
thread expires each block individually when its timer elapses; on expiry the IP
is decremented out of the Bloom filter and dropped from the leaky bucket, so
it starts fresh. The reaper idles whenever the leaky bucket tracks no IPs. The
dashboard shows a live per-IP countdown to each block's expiry.
A Scapy thread sniffs the mitigation interface for observability only — packets/sec and protocol mix. Enforcement happens at the proxy layer, where dropping a connection means closing the socket without ever opening one upstream.
Set on the mitigation service in docker-compose.yml:
| Variable | Default | Meaning |
|---|---|---|
BUCKET_CAPACITY |
20 | Max tokens per IP before overflow |
BUCKET_LEAK_RATE |
10 | Tokens drained per second |
BLOOM_BITS |
100000 | Number of counter slots (m) |
BLOOM_HASHES |
4 | Number of hash functions (k) |
BLOCK_TTL_SEC |
60 | How long an IP stays blocked before its block expires |
TARGET_HOST |
172.30.0.10 | Backend host the proxy forwards to |
TARGET_PORT |
8080 | Backend port |
ALLOWLIST |
(empty) | Comma-separated IPs that always bypass blocking |
ALERT_BPS |
20 | Blocks/sec that trips the dashboard "under attack" status |
At m=100,000 / k=4, the estimated false-positive rate stays well under 0.001% for up to ~1,000 blocked IPs. The dashboard shows the live estimate.
A source IP is classified as abusive by its per-IP leaky bucket. Each
connection adds one token; the bucket drains at BUCKET_LEAK_RATE tokens per
second (default 10/s) and overflows once it would exceed BUCKET_CAPACITY
(default 20).
So, for a source sending at a sustained rate of R requests/second:
- R ≤ 10 req/s — the bucket drains as fast as it fills; the source is never classified as an attack.
- R > 10 req/s — the bucket fills at
R − 10tokens/s and overflows after roughly20 / (R − 10)seconds. The first 20 requests are tolerated as a burst allowance.
On overflow the IP is added to the Bloom filter and every later connection is
dropped (bloom_hit) until its block TTL expires. In short: the per-IP
attack threshold is a sustained rate above BUCKET_LEAK_RATE (10 req/s), after
a burst of BUCKET_CAPACITY (20 requests).
Separately, the dashboard's red "UNDER ATTACK" status is a system-wide
indicator (it does not change enforcement) and trips when the measured
blocks/second crosses ALERT_BPS (default 20).
- At runtime (recommended): edit the environment variables on the
mitigationservice indocker-compose.yml—BUCKET_LEAK_RATE,BUCKET_CAPACITY, andALERT_BPS— then restart. - In code (the defaults): the leaky-bucket thresholds are set in
mitigation/main.py, in theLeakyBucketRegistry(...)constructor call —env_float("BUCKET_CAPACITY", 20.0)andenv_float("BUCKET_LEAK_RATE", 10.0). The "under attack" threshold default is inmitigation/dashboard.py:alert_threshold_bps = int(os.environ.get("ALERT_BPS", 20)).
mitigation/
main.py wiring + thread orchestration (sampler, reaper)
bloom_filter.py counting Bloom filter, double-hashing, add/remove
leaky_bucket.py per-IP buckets, LRU bounded
blocker.py evaluate(ip) → (allowed, reason) + per-IP block table
proxy.py TCP listener, splices to backend on accept
sniffer.py scapy thread, stats only
geoip.py best-effort country/ASN lookup, cached
dashboard.py Flask dashboard + REST API + /metrics
target/server.py trivial echo backend
attacker/flood.py multi-threaded TCP connect flood
The dashboard at http://localhost:5001 auto-refreshes every second and shows:
- A pulsing system-status panel — green "SYSTEM PROTECTED", red "UNDER
ATTACK" once blocks/sec crosses
ALERT_BPS. - Decision counters (allowed, blocked, block rate, reasons).
- Packets/second received, and leaky-bucket throughput — leak rate, per-IP capacity, packets remaining in buckets, and packets forwarded.
- Counting Bloom filter stats (IPs added/removed, slots used, FP rate).
- The live allowlist with Add / Remove controls.
- A rolling 60-second traffic chart (packets/sec and blocks/sec).
- Active blocks with a per-IP TTL countdown.
- A manual block control and the recent-blocks table with Geo-IP data.
IPs on the allowlist skip the Bloom filter and leaky bucket entirely — they are
always forwarded and counted as allowed. Useful for trusted monitoring or
health-check sources that should never be throttled.
Seed it at startup with the ALLOWLIST env var, or manage it live from the
dashboard's Allowlist card, or via the API:
curl -X POST localhost:5001/api/allow -H 'Content-Type: application/json' -d '{"ip":"172.30.0.50"}'
curl -X POST localhost:5001/api/unallow -H 'Content-Type: application/json' -d '{"ip":"172.30.0.50"}'Block or unblock an IP by hand:
curl -X POST localhost:5001/api/block -H 'Content-Type: application/json' -d '{"ip":"203.0.113.7"}'
curl -X POST localhost:5001/api/unblock -H 'Content-Type: application/json' -d '{"ip":"203.0.113.7"}'Manual blocks are kept in a separate set, so they never expire and are cleared
only by unblock. Automatic blocks (bucket overflow) instead expire on their
own per-IP TTL. Each blocked IP in recent_blocks is enriched with a
best-effort Geo-IP lookup (country + ASN); private/reserved addresses are
labelled locally without a network call.
| Method & path | Purpose |
|---|---|
GET / |
The HTML dashboard |
GET /api/stats |
Full JSON snapshot of engine state |
GET /metrics |
Prometheus-format metrics (see below) |
GET /healthz |
Liveness probe — {"status": "ok", "uptime_sec": N} |
GET /api/blocks.csv |
Recent blocks as a downloadable CSV |
POST /api/block |
Manually block an IP — body {"ip": "..."} |
POST /api/unblock |
Remove a manual block — body {"ip": "..."} |
POST /api/allow |
Add an IP to the allowlist — body {"ip": "..."} |
POST /api/unallow |
Remove an IP from the allowlist — body {"ip": "..."} |
GET /api/stats returns a JSON snapshot:
{
"uptime_sec": 60,
"packets_total": 5200,
"packets_per_sec": 5,
"proto_counts": {"TCP": 5200},
"allowed": 25,
"blocked": 1305,
"block_reasons": {"bucket_overflow": 1, "bloom_hit": 1304},
"bloom": {"added": 1, "bits": 100000, "hashes": 4, "removed": 0,
"fill_ratio": 0.00004, "estimated_fp_rate": 0.0},
"bucket": {"leak_rate": 10.0, "capacity": 20.0,
"packets_remaining": 6.0, "tracked_ips": 2},
"active_blocks": [{"ip": "172.30.0.30", "expires_in": 42.0}],
"top_buckets": [{"ip": "172.30.0.30", "level": 0.0}],
"recent_blocks": [{"ts": 1778..., "ip": "172.30.0.30", "reason": "bloom_hit",
"geo": {"country": "Private network", "asn": "-"}}],
"allowlist": ["172.30.0.50"],
"manual_blocks": ["203.0.113.7"],
"history": [{"ts": 1778..., "pps": 5, "bps": 0}]
}Levee exposes its counters in the Prometheus text exposition
format at
GET /metrics, so they can be scraped, graphed, and alerted on.
1. Quick look from the shell — just curl the endpoint:
curl http://localhost:5001/metricsExample output:
# HELP levee_uptime_seconds Time since the mitigation engine started.
# TYPE levee_uptime_seconds gauge
levee_uptime_seconds 312
# HELP levee_packets_total Packets observed by the sniffer.
# TYPE levee_packets_total counter
levee_packets_total 5200
# HELP levee_packets_per_second Packets observed in the last second.
# TYPE levee_packets_per_second gauge
levee_packets_per_second 5
# HELP levee_connections_allowed_total Connections forwarded to the backend.
# TYPE levee_connections_allowed_total counter
levee_connections_allowed_total 25
# HELP levee_connections_blocked_total Connections dropped at the proxy.
# TYPE levee_connections_blocked_total counter
levee_connections_blocked_total 1305
# HELP levee_blocks_by_reason_total Blocked connections grouped by reason.
# TYPE levee_blocks_by_reason_total counter
levee_blocks_by_reason_total{reason="bucket_overflow"} 1
levee_blocks_by_reason_total{reason="bloom_hit"} 1304
# HELP levee_bloom_added Total IPs inserted into the Bloom filter.
# TYPE levee_bloom_added gauge
levee_bloom_added 1
# HELP levee_bloom_estimated_fp_rate Estimated Bloom false-positive rate.
# TYPE levee_bloom_estimated_fp_rate gauge
levee_bloom_estimated_fp_rate 0.0
2. Scrape it with a Prometheus server. Add Levee as a scrape target in
prometheus.yml:
scrape_configs:
- job_name: levee
scrape_interval: 5s
static_configs:
- targets: ['localhost:5001']If you run Prometheus inside Docker, replace
localhostwith the mitigation container's address —host.docker.internal:5001, or172.30.0.20:5001if Prometheus joins thelevee_netbridge network.
Reload Prometheus, then confirm the target is UP under Status → Targets.
You can now query the metrics in the Prometheus expression browser or in
Grafana, for example:
rate(levee_connections_blocked_total[1m]) # blocks per second
levee_packets_per_second # live packet rate
levee_bloom_estimated_fp_rate # Bloom false-positive estimate
| Metric | Type | Meaning |
|---|---|---|
levee_uptime_seconds |
gauge | Seconds since the engine started |
levee_packets_total |
counter | Total packets observed by the sniffer |
levee_packets_per_second |
gauge | Packets seen in the last second |
levee_connections_allowed_total |
counter | Connections forwarded to the backend |
levee_connections_blocked_total |
counter | Connections dropped at the proxy |
levee_blocks_by_reason_total |
counter | Blocks, labelled by reason |
levee_bloom_added |
gauge | Total IPs inserted into the Bloom filter |
levee_bloom_estimated_fp_rate |
gauge | Estimated Bloom false-positive rate |
Note on "logs":
/metricsexposes numeric counters, not a text log. For a human-readable record of recent blocks use the dashboard's recent-blocks table orGET /api/blocks.csv; for the engine's stdout log use./start.sh logs.
This is a teaching-grade demo, not a production WAF. See SECURITY.md for the full security model. In particular:
- A Bloom false positive (extremely rare) blocks an IP that was never in the per-IP table, so it cannot be expired by the reaper.
- The per-IP bucket registry is LRU-bounded at 10k entries.
- Enforcement is at L4 (TCP
accept), so a pure SYN flood that never completes the handshake is observed by the sniffer but not throttled. - The engine is a single Python process — the GIL caps throughput at one core.
MIT.