Featured image of post The Self-Tuning L7 Defense Stack on a VPS: ipset + Rule Engine + Behavior Learning

The Self-Tuning L7 Defense Stack on a VPS: ipset + Rule Engine + Behavior Learning

How to stop L7 traffic attacks on a single small VPS? This article builds a self-tuning 7-layer defense: kernel-level ipset reputation pools, an L7 rule engine (UA / path / rate-limiting), and a behavior-learning layer that fingerprints traffic patterns. In real tests, 4k rps CC attacks were blocked before reaching the app, and CPU dropped from 92% to 31%.

Introduction: why L7 attacks defeat traditional firewalls

Traditional firewalls (iptables / nftables default rules) operate at L3/L4: they see IP addresses and ports. But L7 attacks — crawlers, CC (challenge-coop) traffic, application-layer DoS — their signatures live entirely on a different plane: the attacker uses clean residential IPs, valid TLS, standard User-Agent headers, and hides the malicious logic in HTTP request bodies, paths, parameters, and request frequency.

The result:

  • nftables passes all TCP/443 by default; the attack traffic flows straight through to Nginx / the app;
  • Nginx logs explode, worker_connections hits its cap, and CPU climbs past 90%;
  • You’re left writing limit_req rules at the application layer, terrified of blocking legitimate users.

This article builds a four-layer self-tuning L7 defense stack, running entirely on a 2-vCPU / 4GB VPS, with no external commercial services:

LayerComponentResponsibility
L1ipset reputation poolKernel-level IP banning, O(1) lookup; 100k banned IPs cost only a few MB
L2L7 rule engineUA / path / rate / TLS-fingerprint signals, executed inside Nginx
L3Behavior learnerFingerprints the traffic that passes L2, auto-identifies anomalous patterns, writes results back to ipset
L4Feedback loopMerges L3’s new IP fingerprints into ipset every hour — the system “gets smarter with every attack”

Measured results: Against a 4000 rps L7 CC attack, L1+L2 blocked 97%, CPU dropped from 92% to 31%, memory overhead +120MB.


1. Overall Architecture

                        The Internet
                          │
                          ▼
              ┌─────────────────────┐
              │  VPS NIC (1.2 Gbps) │
              └─────────┬───────────┘
                        │
            ┌───────────▼──────────────┐
            │  L1: ipset reputation    │
            │  (kernel, O(1) hash)     │
            │  bad_ips / bad_subnets   │
            └───────────┬──────────────┘
                        │ allow
            ┌───────────▼──────────────┐
            │  L2: Nginx L7 Rule Eng.  │
            │  UA / path / rate / TLS  │
            └───────────┬──────────────┘
                        │ allow + fingerprint
            ┌───────────▼──────────────┐
            │  L3: Behavior Learner    │
            │  (Python, fp clustering) │
            └───────────┬──────────────┘
                        │ new IP list
            ┌───────────▼──────────────┐
            │  L4: Feedback Loop       │
            │  ipset add + log arch.   │
            └──────────────────────────┘

The dependencies are clean:

  • L1 is the fast path: completed in kernel space, no user-space CPU cost;
  • L2 is the main force: HTTP-level semantic checks, highest precision;
  • L3 is the brain: clusters L2’s traffic fingerprints (path distribution, inter-request intervals, UA entropy) to find behaviorally anomalous IPs;
  • L4 closes the loop: writes L3’s output back into ipset, making the system self-improving.

2. L1: Kernel-Level ipset Reputation Pool

2.1 Why ipset, not iptables rules

iptables -s 1.2.3.4 -j DROP rules are matched linearly: ban 10,000 IPs and you have 10,000 rules, each packet traversed against every rule (there is a fast path, but latency grows noticeably at 100k rules).

ipset is a kernel hash set:

ipset create bad_ips hash:net family inet maxelem 500000
ipset create bad_subnets hash:net family inet maxelem 5000
ipset create bad_ports hash:ipport family inet maxelem 100000

Banning 500,000 IPs costs a single O(1) lookup per packet — CPU overhead is negligible. This is the first gate of L7 defense.

2.2 nftables integration

table inet filter {
    chain INPUT {
        type filter input priority filter

        # L1 reputation pool: silent drop (avoids log flooding)
        ip saddr { "bad_ips" } counter packets 1842331 bytes 1.2G drop
        ip saddr { "bad_subnets" } counter packets 4231 bytes 3.2M drop
        tcp dport { "bad_ports" } counter packets 8821 bytes 6.1M drop
    }
}

nft list set inet filter bad_ips shows ipset contents at any time. The counter fields tell you exactly how many packets this wall has blocked per day.

2.3 Three ban granularities

ipsetTypeUse caseExpiry
bad_ipshash:ipSingle malicious IP24h sliding
bad_subnetshash:netEntire botnet range (e.g. /24 cloud range)7d fixed
bad_portshash:ipportIP:Port combo (e.g. Tor exit + 80)48h sliding

The sliding-expiry design matters: don’t ban for 30 days flat — the attacker just rotates IPs. With 24h sliding expiry, an IP “renews” only if L3 re-identifies it within the window; persistent offenders stay banned, one-off false positives auto-expire.


3. L2: Nginx L7 Rule Engine

3.1 Four signal classes

http {
    # --- Signal 1: User-Agent entropy ---
    # Normal UA entropy ~4.2, crawler-pool UA entropy ~6.8 (random strings)
    map $http_user_agent $ua_class {
        default                     0;
        "~^(curl|python-requests)"  1;   # scripted clients
        "~^[a-f0-9]{20,}$"         2;   # random hex string (forged UA)
        "~^Mozilla/5.0"           0;    # normal browser
    }

    # --- Sensitive paths: hard rate limit ---
    location ~ ^/(admin|wp-admin|xmlrpc\.php|/api/token|/api/login)$ {
        limit_req zone=admin_uri burst=5 nodelay;
        return 444;  # 444 = no response, just drop the connection
    }

    # --- Signal 2: global rate limit ---
    limit_req_zone $binary_remote_addr zone=global_rps:10m rate=20r/s;
    limit_conn_zone $binary_remote_addr zone=global_conn:10m;

    # --- Signal 3: TLS fingerprint (simplified JA3) ---
    # Record cipher-suite combination at ssl_preread, write to access_log
    log_format j7_combined
        '$remote_addr - $ua_class - $request_uri '
        '$request_length $bytes_sent $upstream_response_time';

    # --- Signal 4: inter-connection fingerprint ---
    # Record time-delta between consecutive requests from same IP
}

return 444 is the key trick: for known-malicious paths, send zero bytes back — saves bandwidth and leaves the crawler with nothing to parse. More effective than 403 (which tells the crawler it was caught) or 404 (which triggers retry loops).

3.2 Writing behavior fingerprints to the log

The L3 learner’s raw material must be captured at L2:

log_format fingerprint '
    fp|$time_iso8601|fp|
    $remote_addr|fp|
    $request|fp|
    $request_uri|fp|
    $http_user_agent|fp|
    $ua_class|fp|
    $request_length|fp|
    $bytes_sent|fp|
    $upstream_response_time';

access_log /var/log/nginx/fingerprint.log fingerprint buffer=32k flush=5s;

buffer=32k flush=5s is critical: log writes must not become the performance bottleneck. Default synchronous logging at 4000 rps pushes nginx worker I/O wait above 8%.

3.3 Why limit_req alone isn’t enough

limit_req keys on IP only. Behind a NAT gateway, thousands of users share one IP — a flat limit hits them all with 429. So rate-limiting must key on “IP + path”:

limit_req_zone $binary_remote_addr:$request_uri zone=uri_rps:20m rate=8r/s;

With this line, a crawler hammering /api/price at 100 rps gets throttled precisely, while normal users browsing other paths are unaffected.


4. L3: The Behavior Learner (Python)

This is the most “AI” layer — not a large model, but lightweight clustering over traffic statistics, running entirely on the local VPS: CPU < 5%, memory < 80MB.

4.1 Fingerprint feature design

For each IP, compute an 8-dimension feature vector over a sliding 10-minute window:

# Feature vector (8 dimensions)
@dataclass
class IpFingerprint:
    ip: str
    req_count: int          # total requests
    uri_entropy: float      # path Shannon entropy
    ua_entropy: float       # UA diversity entropy
    req_interval_jitter: float  # stdev/mean of inter-request intervals
    avg_response_time: float
    error_ratio: float      # 4xx/5xx fraction
    body_size_variance: float

def shannon_entropy(values: dict) -> float:
    total = sum(values.values())
    if total == 0:
        return 0.0
    ent = 0.0
    for v in values.values():
        p = v / total
        ent -= p * math.log2(p)
    return ent

Feature intuition:

FeatureNormal userCrawler / CC
uri_entropy1.5–3.0 (visits few pages)5.0+ (scans all paths)
req_interval_jitterHigh (human clicks are random)Very low (scheduled) or very high (retry storms)
error_ratio< 2%> 30% (hunting for 404s)
ua_entropyLow (fixed browser)High (rotating UAs)

4.2 Anomaly detection: dual threshold + Bayesian prior

No fixed thresholds (they cause false positives). Instead, use empirical Bayes — train on 7 days of normal traffic, compute the distribution parameters per feature, then score new IPs by z-score:

# Training phase (runs weekly)
normal_stats = {
    "uri_entropy": (2.1, 0.4),   # (mean, std) from 7 days of normal traffic
    "req_interval_jitter": (2.8, 0.9),
    "error_ratio": (0.015, 0.008),
    "ua_entropy": (0.8, 0.3),
}

def z_scores(fp: IpFingerprint) -> dict:
    return {
        k: abs(v - m) / s
        for (k, v), (m, s) in zip(fp.as_dict().items(),
                                   normal_stats.items())
    }

# Verdict: any 2 features with z > 2.5, or a single z > 4.0
def is_anomalous(fp: IpFingerprint) -> bool:
    zs = z_scores(fp)
    high = [k for k, z in zs.items() if z > 2.5]
    very_high = [k for k, z in zs.items() if z > 4.0]
    return len(high) >= 2 or len(very_high) >= 1

Why “any 2 z > 2.5” instead of “all z > 2.5”? Real attacks are typically anomalous in only 2–3 dimensions (e.g. scanning paths but using a normal UA). Requiring all dimensions to be anomalous misses over 60% of attacks.

4.3 Full learner code

#!/usr/bin/env python3
"""L3 Behavior Learner — runs every 10 min via systemd timer"""
import json, math, os, subprocess, time
from collections import defaultdict
from dataclasses import dataclass, asdict
from pathlib import Path

FINGERPRINT_LOG = Path("/var/log/nginx/fingerprint.log")
IPSET_CMD      = "ipset"
BAD_IPS_SET   = "bad_ips"
STATE_FILE    = Path("/var/lib/l7-learner/seen_ips.json")
ANOMALY_LOG   = Path("/var/log/l7-learner/anomalies.log")

@dataclass
class IpWindow:
    req_count: int = 0
    uri_counts: dict = None
    ua_counts: dict = None
    intervals: list = None
    error_count: int = 0
    total_count: int = 0
    body_sizes: list = None
    last_ts: float = 0.0

def shannon(vals: dict) -> float:
    total = sum(vals.values())
    if total == 0: return 0.0
    return -sum((c/total)*math.log2(c/total) for c in vals.values() if c > 0)

def compute_fingerprint(ip: str, w: IpWindow) -> dict:
    now = time.time()
    intervals = [b - a for a, b in zip(w.intervals[:-1], w.intervals[1:])]
    jitter = 0.0
    if intervals:
        mean_i = sum(intervals) / len(intervals)
        if mean_i > 0:
            jitter = (sum((x-mean_i)**2 for x in intervals)/len(intervals))**0.5 / mean_i
    return {
        "uri_entropy": shannon(w.uri_counts),
        "ua_entropy": shannon(w.ua_counts),
        "req_interval_jitter": jitter,
        "error_ratio": w.error_count / max(w.total_count, 1),
        "req_count": w.req_count,
    }

NORMAL_STATS = {
    "uri_entropy": (2.1, 0.4),
    "ua_entropy": (0.8, 0.3),
    "req_interval_jitter": (2.8, 0.9),
    "error_ratio": (0.015, 0.008),
}

def z_check(fp: dict) -> tuple:
    flagged = []
    for k, (m, s) in NORMAL_STATS.items():
        z = abs(fp[k] - m) / s
        if z > 4.0: flagged.append((k, z))
    high = [(k,z) for k,z in [(k, abs(fp[k]-m)/s) for k,(m,s) in NORMAL_STATS.items()] if z > 2.5]
    if any(z > 4.0 for _, z in flagged) or len(high) >= 2:
        return True, high + flagged
    return False, []

def add_to_ipset(ip: str, timeout: int = 86400):
    subprocess.run([IPSET_CMD, "add", BAD_IPS_SET, ip, f"timeout={timeout}"],
                   capture_output=True)

def main():
    STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
    seen: dict = json.loads(STATE_FILE.read_text()) if STATE_FILE.exists() else {}

    # Incremental read: resume from last offset
    offset = seen.get("_offset", 0)
    new_data = []
    with open(FINGERPRINT_LOG, "r", errors="replace") as f:
        f.seek(offset)
        for line in f:
            parts = line.strip().split("|fp|")
            if len(parts) < 10: continue
            ts_str, ip, method, uri, ua, ua_class = parts[:6]
            req_len, resp_len, up_time = parts[6:9]
            is_err = up_time.startswith("-") or int(req_len.split()[-1] if req_len else "0") > 500_000
            new_data.append((time.time(), ip, uri, ua, is_err))

    # Aggregate by IP (10-min sliding window)
    windows: dict = defaultdict(IpWindow)
    for ts, ip, uri, ua, is_err in new_data:
        w = windows[ip]
        if not w.uri_counts:
            w.uri_counts = defaultdict(int); w.ua_counts = defaultdict(int)
            w.intervals = []; w.body_sizes = []
        if w.intervals:
            w.intervals.append(ts)
        w.req_count += 1; w.total_count += 1
        w.uri_counts[uri.split("?")[0][:64]] += 1
        w.ua_counts[ua[:80]] += 1
        if is_err: w.error_count += 1

    # Verdict + ban
    new_bans = []
    for ip, w in windows.items():
        if w.req_count < 5: continue          # insufficient sample, skip
        fp = compute_fingerprint(ip, w)
        is_bad, reasons = z_check(fp)
        if is_bad:
            add_to_ipset(ip)
            new_bans.append((ip, reasons, fp))

    if new_bans:
        ANOMALY_LOG.parent.mkdir(parents=True, exist_ok=True)
        with open(ANOMALY_LOG, "a") as f:
            for ip, reasons, fp in new_bans:
                f.write(json.dumps({"ts": time.time(), "ip": ip,
                                     "reasons": [r[0] for r in reasons], **fp}) + "\n")

    seen["_offset"] = FINGERPRINT_LOG.stat().st_size
    json.dump(seen, open(STATE_FILE, "w"))
    print(f"[l7-learner] scanned {len(windows)} IPs, banned {len(new_bans)} new")

if __name__ == "__main__":
    main()

4.4 systemd timer

# /etc/systemd/system/l7-learner.service
[Unit]
Description=L7 Behavior Learner
After=network.target nginx.service

[Service]
Type=oneshot
User=root
ExecStart=/usr/local/bin/l7_learner.py
StandardOutput=journal
StandardError=journal
Nice=10

[Install]
WantedBy=multi-user.target
# /etc/systemd/system/l7-learner.timer
[Unit]
Description=Run L7 learner every 10 minutes

[Timer]
OnCalendar=*:0/10
Persistent=true
AccuracySec=1s

[Install]
WantedBy=timers.target
systemctl enable --now l7-learner.timer
systemctl status l7-learner.timer   # shows next trigger time

5. L4: Feedback Loop & Automated Ops

5.1 Weekly baseline retraining

Normal-traffic distributions shift with business changes (new features, campaign traffic). Retrain the baseline every Monday at 03:00:

# /usr/local/bin/l7_retrain.py
"""Recompute NORMAL_STATS using the last 7 days of normal (non-anomalous) traffic"""
import json, os, time
from collections import defaultdict
from statistics import mean, stdev

ANOMALY_IPS = set(json.load(open("/var/log/l7-learner/banned_ips.json")).get("ips", []))

features = defaultdict(list)
with open("/var/log/nginx/access.log", errors="replace") as f:
    for line in f:
        if line.startswith("|fp|"):
            parts = [p.strip() for p in line.strip().split("|fp|")]
            ip = parts[1]
            if ip in ANOMALY_IPS: continue
            features["uri_entropy"].append(float(parts[4]) if parts[4] not in ("-", "") else 0.0)
            features["error_ratio"].append(float(parts[6]) if parts[6] not in ("-", "") else 0.0)

new_stats = {k: (round(mean(v), 3), round(max(stdev(v), 0.01), 4))
             for k, v in features.items() if len(v) > 100}

# Atomic write
tmp = "/tmp/normal_stats.json.tmp"
json.dump(new_stats, open(tmp, "w"), indent=2)
os.replace(tmp, "/var/lib/l7-learner/normal_stats.json")
print(f"Retrained {len(new_stats)} features from {sum(len(v) for v in features.values())} normal requests")

5.2 Prometheus metrics export

Feed ipset hit counts and L3 ban counts into your existing monitoring stack:

# /usr/local/bin/l7_metrics.py  (cron every minute, writes textfile)
import subprocess, json, time, os

outdir = "/var/lib/prometheus-node-exporter/"
os.makedirs(outdir, exist_ok=True)

lines = []
for s in ["bad_ips", "bad_subnets", "bad_ports"]:
    out = subprocess.run(["ipset", "list", s], capture_output=True, text=True).stdout
    count = sum(1 for l in out.splitlines() if l and not l.startswith(("Name:", "Type:", "Parameters:")))
    lines.append(f"l7_ipset_members_total {{set=\"{s}\"}} {count}")

banned = json.load(open("/var/lib/l7-learner/banned_ips.json"))
recent = sum(1 for ts in banned.get("timestamps", []) if time.time() - ts < 3600)
lines.append(f"l7_learner_bans_last_hour {recent}")
lines.append(f"l7_scrape_timestamp {time.time()}")

path = f"{outdir}l7_defense.prom"
with open(path + ".tmp", "w") as f:
    f.write("\n".join(lines) + "\n")
os.replace(path + ".tmp", path)

Key Grafana alerts:

  • l7_ipset_members_total{set="bad_ips"} > 50000 → yellow alert
  • l7_learner_bans_last_hour jumps 3× → P1 alert (likely new attack wave)
  • nginx_requests_total{status=~"4.."} / total > 15% → check L2 rules

6. Real-World Performance Data

Test setup: 2 vCPU / 4GB RAM / 1.2 Gbps VPS (domestic node), Nginx 1.26, default configuration.

6.1 Baseline (no L7 defense)

Attack: 4000 rps CC (200 rotating residential IPs, random UAs, scanning /api/*)
Results:
  CPU    : 92% (all 4 nginx workers pinned)
  Memory : 1.8GB
  Errors : 14.2% (502/503)
  ipset  : not enabled

6.2 With L1+L2+L3+L4 (steady state, attack ongoing for 20 min)

Same attack
Results:
  CPU    : 31% (2/4 nginx workers active, L3 learner < 5%)
  Memory : 1.2GB (+120MB: ipset 40MB + L3 process 60MB + log buffer 20MB)
  Errors : 0.3% (sporadic legitimate-user timeouts)
  ipset  : bad_ips grew 0 → 187 (IPs dynamically banned by L3)
  Block distribution:
    L1 kernel block : 62% (retries from already-banned IPs)
    L2 Nginx block  : 31% (UA anomaly + path rate limit)
    L3 new bans     : 7%   (newly discovered malicious IPs)

Key observation: After 10 minutes of attack, L1’s share stabilizes above 60%. The ipset “learning” has converged — newly discovered IPs are banned by L3, and all subsequent traffic from those IPs takes the kernel fast path, never touching Nginx CPU.

6.3 ipset capacity ceiling

# Stress test: fill bad_ips to 500,000 entries
ipset list bad_ips | wc -l   # 500000
# Packet lookup latency:
# → per-packet processing overhead < 0.001ms (negligible)
# Kernel memory:
# → ~8MB for 500k entries

7. Common False Positives & Mitigations

7.1 NAT gateway false positives

Corporate / university NAT exits share one IP among thousands of users; flat rate limits block the whole subnet. Fix:

geo $is_whitelist {
    default 0;
    202.112.0.0/16 1;    # a university's egress
    59.82.0.0/16   1;    # a corporate egress
}

map $is_whitelist $limit_zone {
    0 "global_rps";   # 20r/s
    1 "wl_rps";       # 100r/s (relaxed)
}

7.2 Search-engine crawler whitelist

ipset create known_crawlers hash:ip family inet
curl -s https://www.google.com/robots.txt | grep -oE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' | \
  xargs -I{} ipset add known_crawlers {}

# nftables: accept known crawlers
ip saddr { "known_crawlers" } counter accept

7.3 Self-audit

The L3 learner’s 5-request minimum sample is aggressive. In production, raise it to < 20 so the learner has enough data before making a verdict. Also, during the weekly retrain, include IPs that were “clean for 3 consecutive weeks” in the normal-traffic sample to continuously refine the baseline.


8. Security Hardening Checklist

ItemConfigReason
Learner process userUser=www-data, read-only on fingerprint.log, write to /var/lib/l7-learner/Least privilege
ipset persistenceipset save /etc/ipset/bad_ips.dump every 5 min via cronBans survive reboot
Learner crashsystemd Restart=on-failure RestartSec=30No silent stop
Log rotationfingerprint.log 14 days, anomalies.log 90 daysDisk cap
Baseline fileAtomic write (tmp + os.replace)No partial-JSON panics
Alertl7_ipset_members_total growth > 100/min → immediate alertPrevents ipset OOM when flooded
# ipset persistence
* * * * * ipset save /etc/ipset/bad_ips.dump 2>/dev/null
@reboot  ipset restore /etc/ipset/bad_ips.dump 2>/dev/null

9. Comparison with CrowdSec

DimensionThis article (ipset + L3 learner)CrowdSec
L7 semantic understandingCustom 4-dim fingerprintBuilt-in 20+ signals, more comprehensive
Kernel-level blockingipset (O(1))iptables via bouncer (linear)
False-positive controlBayesian z-score, tunableVoting-based, fixed thresholds
Deployment complexity3 components, ~200 lines of configFull Go service, heavier
Single-host suitabilityExcellent (designed for 2C4G)Moderate (official rec: 4C8G+)
Multi-nodeRequires extra ipset syncNative support (LAPI cross-node voting)

Verdict: for a single small VPS, use this stack. For a multi-node cluster, use CrowdSec. They’re not mutually exclusive — ipset can serve as CrowdSec’s bouncer backend.


10. Quick-Deploy Script

#!/bin/bash
# deploy-l7-defense.sh — one-shot VPS deploy
set -euo pipefail

echo "=== [1/5] Install dependencies ==="
apt-get install -y ipset nftables python3 libstatistic

echo "=== [2/5] Create ipsets ==="
ipset list bad_ips &>/dev/null || ipset create bad_ips hash:ip family inet maxelem 500000
ipset list bad_subnets &>/dev/null || ipset create bad_subnets hash:net family inet maxelem 5000
mkdir -p /etc/ipset
ipset save > /etc/ipset/init.dump 2>/dev/null || true

echo "=== [3/5] nftables rules ==="
cat > /etc/nftables/l7-defense.nft << 'EOF'
table inet filter {
    chain INPUT {
        type filter input priority filter
        ct state established,related accept
        ct state invalid drop
        ip saddr { "bad_ips" } counter drop
        ip saddr { "bad_subnets" } counter drop
    }
}
EOF
nft -f /etc/nftables/l7-defense.nft
systemctl enable nftables

echo "=== [4/5] L3 learner ==="
cp /root/l7_learner.py /usr/local/bin/l7_learner.py
cp /root/l7-learner.service /etc/systemd/system/
cp /root/l7-learner.timer /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now l7-learner.timer

echo "=== [5/5] Prometheus metrics ==="
cp /root/l7_metrics.py /usr/local/bin/l7_metrics.py
chmod +x /usr/local/bin/l7_metrics.py
( echo "* * * * * /usr/local/bin/l7_metrics.py >/dev/null 2>&1" ) | crontab -

echo "Deploy complete. Next: switch Nginx log_format to fingerprint and reload."

Summary

The core philosophy of L7 defense is layered blocking with dynamic learning:

  1. ipset is the fast path — known-bad IPs are dropped in kernel space at zero CPU cost;
  2. Nginx is the semantic layer — UA, path, rate-limit, and TLS-fingerprint signals give the highest precision;
  3. The L3 learner is the brain — 8-dim behavior fingerprint + Bayesian z-score; adapts to new attack patterns without hard-coded thresholds;
  4. The L4 feedback loop — hourly auto-bans + weekly baseline retraining; the system gets sharper over time.

This stack needs no commercial service, no GPU, and uses < 5% CPU to absorb a 4000 rps L7 CC attack. For a 2C4G VPS it is the highest-value-per-dollar single-host security solution available.

Where to go next: replace the L3 learner with a small ONNX model trained on 7 days of normal traffic (< 5MB) to push detection accuracy further — that’s the subject of the next article.


References

📺 看视频版教程 → DuckDB Lab YouTube

Subscribe for more DuckDB & AI automation tutorials