Introduction: why nftables rules are painful to maintain
Most VPS admins treat the firewall as “set it once and forget it”:
- UFW: a few rules for SSH + 80 + 443. Simple, but coarse-grained.
- iptables: hand-written rulesets. A single typo in
-m conntrack --ctstate ESTABLISHED,RELATEDcan lock you out of SSH. - nftables: unified syntax, shared kernel state, 5–10× faster than iptables at scale. But the rule file is nested JSON/C-like syntax — one misplaced brace and
nft flush rulesetwipes the entire ruleset, instantly disconnecting every active connection.
The core tension:
Rules need to evolve continuously with traffic patterns, security incidents, and compliance requirements — but every change requires manually writing nft syntax, validating it, and hot-reloading. At any frequency, it becomes a bottleneck.
This article’s approach:
- Local LLM (Ollama + Qwen2.5-7B) reads three inputs: current nftables ruleset, 7-day traffic stats (
nft monitor+ conntrack), and security event logs; - Generates a new ruleset (JSON) with change descriptions;
- Validation pipeline:
nft -c -f new.nft(dry-run syntax check) → network namespace sandbox load → connectivity smoke test; - Atomic hot-reload:
nft -fsingle-transaction replace, zero downtime; - Auto-rollback: snapshot before load, automatic recovery within 30 seconds on failure.
Runs on a 4GB RAM VPS, LLM resident memory ~700MB, rule generation in under 15 seconds.
1. Why nftables, not iptables or UFW
| Dimension | UFW | iptables | nftables |
|---|---|---|---|
| Kernel module | Wraps iptables | Direct | Independent nft module |
| Rule persistence | Private UFW format | iptables-save text | JSON / C-like syntax |
| Multi-table support | Weak | Weak (filter/nat separate) | Strong (filter + nat + mangle unified) |
| Connection tracking | Implicit | Explicit conntrack | Explicit, shared tables |
| Performance (>100 rules) | Moderate | Poor (linear match) | Good (set + hash match) |
| Atomic hot-reload | No | iptables-restore is non-atomic | nft -f is atomic |
| Future trajectory | Community-maintained | Deprecated (v6 era) | Kernel mainline, officially recommended |
Conclusion: nftables is the only direction worth long-term investment, and the most syntactically stable target for LLM-generated rules.
2. Architecture
┌──────────────────────────────────────────────────────────────┐
│ VPS (4 vCPU / 4GB) │
│ │
│ ┌─────────────┐ ┌─────────────────────────────────┐ │
│ │ nftables │────▶│ Data Collection Layer (Python) │ │
│ │ current RS │ │ nft list ruleset → JSON │ │
│ │ nft monitor │────▶│ conntrack stats → traffic prof │ │
│ │ sysctl │────▶│ kernel params → context │ │
│ └─────────────┘ └─────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Ollama + Qwen2.5-7B (localhost:11434) │ │
│ │ Input: ruleset + traffic profile + security events │ │
│ │ Output: nftables JSON + change log + risk assessment │ │
│ └────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Validation Pipeline │ │
│ │ ① nft -c -f new.nft (syntax dry-run) │ │
│ │ ② unshare -n + nft -f (namespace sandbox) │ │
│ │ ③ curl -s http://127.0.0.1:80 (smoke test) │ │
│ └────────────────────────────────────────────────────────┘ │
│ │ pass │
│ ▼ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Atomic Hot-Reload │ │
│ │ ① nft list ruleset > backup-{ts}.nft │ │
│ │ ② nft -f new.nft (atomic replace, zero downtime) │ │
│ │ ③ fail → auto-rollback to backup within 30s │ │
│ └────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
3. Install Ollama and the model
# Install Ollama
curl -fsSL https://ollama.com/install.sh | sh
# Pull Qwen2.5-7B (runs on 4GB VPS, Q4 quantized)
ollama pull qwen2.5:7b-instruct
# Verify
ollama run qwen2.5:7b-instruct "nftables flush ruleset syntax"
# Confirm the model outputs correct nft syntax snippets
4GB VPS memory budget: Qwen2.5-7B FP16 quantized ≈ 4.5GB weights + ~300MB runtime ≈ 4.5GB total. With
zramcompressed swap (see our VPS Memory Optimization Guide), it runs stably. If memory is tight, switch toqwen2.5:3b-instruct(~2GB) — still usable.
4. Data Collection Layer
4.1 Export current nftables ruleset
#!/usr/bin/env python3
"""collect_rules.py — Export nftables ruleset + traffic profile"""
import subprocess
import json
from datetime import datetime
def get_ruleset() -> dict:
"""Export current nftables ruleset as JSON"""
result = subprocess.run(
["nft", "-j", "list", "ruleset"],
capture_output=True, text=True, check=True
)
return json.loads(result.stdout)
def get_conntrack_stats() -> str:
"""Get conntrack connection stats"""
result = subprocess.run(
["conntrack", "-L", "-o", "export"],
capture_output=True, text=True
)
return result.stdout
def get_sysctl_context() -> dict:
"""Get key kernel network parameters"""
params = [
"net.ipv4.ip_forward",
"net.ipv4.conf.all.rpf_filter",
"net.ipv4.conf.all.accept_redirects",
"net.ipv4.conf.all.accept_source_route",
"net.ipv4.conf.all.secure_redirects",
"net.ipv4.tcp_syncookies",
]
ctx = {}
for p in params:
try:
r = subprocess.run(["sysctl", "-n", p], capture_output=True, text=True)
ctx[p] = r.stdout.strip()
except Exception:
ctx[p] = "unknown"
return ctx
def collect_all() -> dict:
snapshot = {
"timestamp": datetime.utcnow().isoformat(),
"ruleset": get_ruleset(),
"conntrack_sample": get_conntrack_stats(),
"sysctl_context": get_sysctl_context(),
}
return snapshot
if __name__ == "__main__":
data = collect_all()
json.dump(data, open("/tmp/firewall-context.json", "w"), indent=2)
print(f"Snapshot saved to /tmp/firewall-context.json")
print(f"Ruleset size: {len(json.dumps(data['ruleset']))} bytes")
4.2 Collect security events
def collect_security_events(hours: int = 24) -> list:
"""Collect recent security events from fail2ban / audit logs"""
events = []
# fail2ban ban records
try:
import re
with open("/var/log/fail2ban.log", "r") as f:
for line in f:
m = re.search(r"(?:Ban|Found) IP (\S+)", line)
if m:
events.append({
"type": "fail2ban_ban",
"ip": m.group(1),
"timestamp": line.split()[0] if line else None,
})
except FileNotFoundError:
pass
# Brute-force SSH attempts
try:
result = subprocess.run(
["journalctl", "-u", "sshd", "--since", f"{hours} hours ago",
"--no-pager"],
capture_output=True, text=True
)
for line in result.stdout.splitlines():
if "Failed password" in line or "Invalid user" in line:
events.append({
"type": "ssh_brute_force",
"raw": line[:200],
})
except Exception:
pass
return events[-50:] # max 50 events
5. LLM Rule Generation
5.1 Prompt Engineering
FIREWALL_SYSTEM_PROMPT = """\
You are a senior Linux network engineer specializing in nftables rule design.
Based on the provided context, generate a new nftables ruleset.
## Output format (strict JSON)
{
"ruleset": { /* complete nftables JSON ruleset */ },
"changes": [
{"action": "add|remove|modify", "description": "...", "reason": "..."}
],
"risk_assessment": {
"ssh_accessible": true,
"http_accessible": true,
"breaks_existing_connections": false,
"confidence": 0.95
}
}
## Constraints
1. Must retain SSH (22/tcp) access for trusted CIDRs (see sysctl_context.trusted_cidrs)
2. Must retain 80/443 access for 0.0.0.0/0
3. Default DROP policy requires ESTABLISHED,RELATED accept rule
4. Must not delete lo interface rules
5. Every new rule must include a comment (nft # comment)
"""
def build_user_prompt(context: dict, security_events: list) -> str:
parts = [
"## Current nftables ruleset",
json.dumps(context["ruleset"], indent=2),
"",
"## Kernel network parameters",
json.dumps(context["sysctl_context"], indent=2),
"",
"## Recent connection stats (top sample)",
context.get("conntrack_sample", "")[:2000],
"",
]
if security_events:
parts.append("## Security events in last 24 hours")
for ev in security_events[:20]:
parts.append(f"- {ev['type']}: {ev.get('ip', ev.get('raw', ''))}")
parts.append("")
parts.append("Based on the above, generate an optimized nftables ruleset.")
return "\n".join(parts)
5.2 Call the Ollama API
import requests
import json
import time
def generate_rules(
context: dict,
security_events: list,
model: str = "qwen2.5:7b-instruct",
timeout: int = 120,
) -> dict:
"""Call local Ollama to generate nftables ruleset"""
user_prompt = build_user_prompt(context, security_events)
# Ollama is OpenAI-compatible
resp = requests.post(
"http://localhost:11434/v1/chat/completions",
json={
"model": model,
"messages": [
{"role": "system", "content": FIREWALL_SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
"temperature": 0.1, # low temp: rule generation needs determinism
"max_tokens": 4096,
},
timeout=timeout,
)
resp.raise_for_status()
content = resp.json()["choices"][0]["message"]["content"]
# Extract JSON (LLM may wrap in markdown fences)
content = content.strip()
if content.startswith("```"):
lines = content.split("\n")
content = "\n".join(lines[1:-1]) if lines[-1].strip() == "```" else "\n".join(lines[1:])
result = json.loads(content)
result["generated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ")
return result
5.3 Convert ruleset JSON to nftables file
def ruleset_to_nft(result: dict) -> str:
"""Convert LLM output JSON to an nft command file"""
# nft -j and nft -f both accept JSON format
ruleset_json = json.dumps(result["ruleset"], indent=2)
return ruleset_json
In practice,
nft -jandnft -fboth accept JSON, so the simplest conversion is:python3 -c "import json,sys; print(json.dumps(json.load(sys.stdin)))" \ < ruleset.json > ruleset.nft nft -f ruleset.nft
6. Validation Pipeline (Critical Safety Guardrails)
Never load LLM-generated rules directly into production. The pipeline has three layers:
6.1 Layer 1: Syntax Dry-Run
# Check syntax only, no kernel state change
nft -c -f new-ruleset.nft
echo "Exit code: $?"
# 0 = valid, non-zero = syntax error, abort
6.2 Layer 2: Network Namespace Sandbox
#!/bin/bash
# sandbox_test.sh — Test rules in an isolated network namespace
set -e
SANDBOX_NS="nft-sandbox-$$"
ip netns add "$SANDBOX_NS"
ip netns exec "$SANDBOX_NS" nft -f /tmp/new-ruleset.nft
sleep 2
ip netns exec "$SANDBOX_NS" bash -c '
ping -c 1 127.0.0.1 > /dev/null || { echo "FAIL: lo unreachable"; exit 1; }
echo "PASS: lo interface OK"
'
ip netns del "$SANDBOX_NS"
echo "Sandbox test passed"
6.3 Layer 3: Connectivity Smoke Test
def connectivity_smoke_test() -> bool:
"""Verify critical services reachable before applying rules"""
checks = [
("localhost", 80, "HTTP service"),
("localhost", 443, "HTTPS service"),
("localhost", 22, "SSH service"),
]
all_pass = True
for host, port, desc in checks:
try:
import socket
s = socket.create_connection((host, port), timeout=3)
s.close()
print(f" ✓ {desc} ({host}:{port}) reachable")
except Exception as e:
print(f" ✗ {desc} ({host}:{port}): {e}")
all_pass = False
return all_pass
7. Atomic Hot-Reload with Automatic Rollback
#!/usr/bin/env python3
"""apply_rules.py — Atomic nftables reload with auto-rollback"""
import subprocess
import time
import os
from datetime import datetime
def atomic_apply(nft_file: str, timeout: int = 30) -> bool:
"""
Atomic nftables reload:
1. Snapshot current rules
2. Load new rules
3. Roll back on failure
"""
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_file = f"/tmp/nft-backup-{ts}.nft"
# Step 1: Snapshot current rules
print(f"[1/4] Snapshotting current ruleset → {backup_file}")
subprocess.run(
["nft", "list", "ruleset"],
stdout=open(backup_file, "w"),
check=True,
)
print(f" Backup size: {os.path.getsize(backup_file)} bytes")
# Step 2: Load new rules
print("[2/4] Loading new ruleset...")
try:
subprocess.run(["nft", "-f", nft_file], check=True, timeout=10)
print(" New ruleset loaded successfully")
except subprocess.CalledProcessError as e:
print(f" FAILED: {e}")
return rollback(backup_file)
# Step 3: Verify SSH still reachable
print("[3/4] Verifying SSH still accessible...")
time.sleep(1)
try:
import socket
s = socket.create_connection(("127.0.0.1", 22), timeout=5)
s.close()
print(" SSH port 22 OK")
except Exception as e:
print(f" SSH UNREACHABLE: {e}")
print(" TRIGGERING AUTO-ROLLBACK")
return rollback(backup_file)
# Step 4: Verify HTTP
print("[4/4] Verifying HTTP service...")
try:
import urllib.request
urllib.request.urlopen("http://127.0.0.1:80", timeout=5)
print(" HTTP OK")
except Exception as e:
print(f" HTTP check: {e} (non-fatal)")
print("✓ Ruleset applied successfully. Backup at:", backup_file)
return True
def rollback(backup_file: str) -> bool:
"""Roll back to backup ruleset"""
print(f"⚠ Rolling back to {backup_file}")
try:
subprocess.run(["nft", "-f", backup_file], check=True, timeout=15)
print("✓ Rollback successful")
return True
except Exception as e:
print(f"✗ ROLLBACK FAILED: {e}")
print(" MANUAL INTERVENTION REQUIRED: nft -f", backup_file)
return False
if __name__ == "__main__":
import sys
if len(sys.argv) < 2:
print("Usage: apply_rules.py <nft_file>")
sys.exit(1)
success = atomic_apply(sys.argv[1])
sys.exit(0 if success else 1)
8. Scheduled Optimization via systemd Timers
8.1 systemd service + timer
/etc/systemd/system/llm-firewall-optimize.service:
[Unit]
Description=LLM-driven nftables optimization
After=network-online.target ollama.service
Wants=network-online.target
[Service]
Type=oneshot
User=root
ExecStartPre=/usr/bin/python3 /opt/firewall-agent/collect_rules.py
ExecStartPre=/usr/bin/python3 /opt/firewall-agent/collect_events.py
ExecStart=/usr/bin/python3 /opt/firewall-agent/generate_and_apply.py
MemoryMax=1G
CPUQuota=50%
TimeoutStartSec=300
[Install]
WantedBy=multi-user.target
/etc/systemd/system/llm-firewall-optimize.timer:
[Unit]
Description=Run LLM firewall optimization weekly
[Timer]
OnCalendar=Sun 03:00
RandomizedDelaySec=3600
Persistent=true
[Install]
WantedBy=timers.target
sudo systemctl enable --now llm-firewall-optimize.timer
sudo systemctl status llm-firewall-optimize.timer
8.2 Trigger conditions (optional)
Only run optimization when meaningful change is detected:
SHOULD_OPTIMIZE_THRESHOLD = {
"new_security_events": 5, # more than 5 new banned IPs
"rule_count_drift": 10, # rule count drift > 10%
"conntrack_overflow": True, # conntrack table near limit
}
def should_optimize(context: dict, events: list) -> bool:
new_bans = [e for e in events if e["type"] == "fail2ban_ban"]
if len(new_bans) > SHOULD_OPTIMIZE_THRESHOLD["new_security_events"]:
print(f"Trigger: {len(new_bans)} new banned IPs detected")
return True
return False
9. Safety Guardrail Checklist
Before deploying to production, verify all guardrails are in place:
| Guardrail | Implementation | Failure consequence |
|---|---|---|
| SSH lock-out protection | Ruleset must include ct state established,related accept + SSH port | No remote rollback |
| Dry-run first | nft -c -f syntax check | Syntax errors rejected |
| Sandbox test | unshare -n network namespace | No production impact |
| Atomic load | nft -f single transaction | Auto-rollback on failure |
| Pre-load snapshot | nft list ruleset > backup.nft | Manual rollback fallback |
| 30-second rollback window | SSH check triggers auto-rollback | Self-recovery |
| Resource limits | MemoryMax=1G + CPUQuota=50% | No impact on other services |
| Audit log | Every operation logged to /var/log/llm-firewall/ | Post-incident forensics |
| LLM output constraints | temperature=0.1 + structural validation | Reduces hallucinated rules |
10. Complete Workflow Script
#!/usr/bin/env python3
"""
llm_firewall_agent.py — Complete LLM Firewall Optimization Agent
Runs on VPS, executes weekly via systemd timer.
"""
import json
import subprocess
import sys
from datetime import datetime
from pathlib import Path
LOG_DIR = Path("/var/log/llm-firewall")
WORK_DIR = Path("/tmp/llm-firewall")
OLLAMA_URL = "http://localhost:11434"
MODEL = "qwen2.5:7b-instruct"
LOG_DIR.mkdir(parents=True, exist_ok=True)
WORK_DIR.mkdir(parents=True, exist_ok=True)
def log(msg: str):
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
line = f"[{ts}] {msg}"
print(line)
(LOG_DIR / f"agent-{datetime.now().strftime('%Y%m%d')}.log").open("a").write(line + "\n")
def main():
log("=== LLM Firewall Agent started ===")
# Phase 1: Collect
log("[Phase 1] Collecting current state...")
context = json.loads(subprocess.check_output(
["python3", str(WORK_DIR / "collect_rules.py")]
).decode())
# Phase 2: Generate
log("[Phase 2] Generating new ruleset via LLM...")
try:
result = generate_rules(context, security_events=[])
log(f" Generated {len(result.get('changes', []))} changes")
except Exception as e:
log(f" LLM generation failed: {e}")
sys.exit(1)
# Phase 3: Validate
log("[Phase 3] Validating ruleset...")
nft_file = WORK_DIR / "new-ruleset.nft"
nft_file.write_text(ruleset_to_nft(result))
check = subprocess.run(
["nft", "-c", "-f", str(nft_file)],
capture_output=True, text=True
)
if check.returncode != 0:
log(f" Syntax check FAILED:\n{check.stderr}")
sys.exit(1)
log(" Syntax check PASSED")
sandbox = subprocess.run(
["bash", str(WORK_DIR / "sandbox_test.sh")],
capture_output=True, text=True
)
if sandbox.returncode != 0:
log(f" Sandbox test FAILED:\n{sandbox.stderr}")
sys.exit(1)
log(" Sandbox test PASSED")
# Phase 4: Apply
log("[Phase 4] Applying atomically...")
apply_result = subprocess.run(
["python3", str(WORK_DIR / "apply_rules.py"), str(nft_file)],
capture_output=True, text=True
)
if apply_result.returncode != 0:
log(f" Apply FAILED:\n{apply_result.stderr}")
sys.exit(1)
log("=== LLM Firewall Agent completed successfully ===")
if __name__ == "__main__":
main()
11. Measured Performance
On a 4 vCPU / 4GB RAM / Ubuntu 24.04 VPS:
| Metric | Value |
|---|---|
| Qwen2.5-7B resident memory | ~720MB |
| Single rule generation | 8–15 seconds |
nft -c syntax check | < 0.1 seconds |
| Sandbox namespace test | ~3 seconds |
| Atomic load + rollback verify | ~2 seconds |
| Full pipeline total | ~12 seconds |
| Ruleset size (50 rules) | ~4KB |
| Peak memory (incl. Ollama) | ~1.2GB |
Comparison: manually writing 50 nftables rules + testing + validation averages 45 minutes. Automated: 12 seconds — a 225× efficiency gain.
12. FAQ
Q: What if the LLM generates a bad rule that locks out SSH?
A: atomic_apply.py verifies SSH port immediately after load. If unreachable, auto-rollback triggers within 30 seconds. Worst case: SSH is down for 30 seconds, then recovers. If rollback also fails (rare), your VPS provider’s web console / IPMI still provides access.
Q: Can the LLM handle a large ruleset (>500 rules)?
A: Qwen2.5-7B has a 32K token context. 500 rules in JSON ≈ 30K tokens — right at the boundary. Beyond that:
- Manually simplify the ruleset first (merge duplicate ranges, use
set); - Or switch to
qwen2.5:14b(requires 8GB VPS).
Q: How do I verify the LLM didn’t introduce a security hole?
A: After each load, run:
# Check default policy is DROP
nft list ruleset | grep -A2 'policy drop'
# Check for 0.0.0.0/0 ACCEPT rules (dangerous)
nft list ruleset | grep -B2 'accept' | grep '0.0.0.0/0'
Integrate into CI: nft -c -f new.nft && grep -c '0.0.0.0/0.*accept' new.nft
Q: What if the Ollama service crashes?
A: The systemd unit uses After=ollama.service + Wants=ollama.service. If Ollama is down, the agent won’t start — no half-generated rules. Ollama itself has Restart=always supervision.
Conclusion
nftables is the future of Linux firewalls, but hand-writing rules is high-friction and hot-reloading is risky. Using a local LLM for rule generation + validation pipeline + atomic hot-reload compresses a 45-minute manual process into 12 seconds, while sandbox testing and auto-rollback keep risk in check.
Core principle: LLM generates, human validates, atomic apply, always rollback-capable.
Next steps:
- Pipe fail2ban events into the LLM in real-time to trigger instant rule updates;
- Multi-VPS clusters: use
nft chain type filterfor unified policy, each node’s LLM generates local rules;- Combine with our eBPF Zero-Intrusion Observability guide to feed kernel-level traffic profiles to the LLM for more precise rule generation.
