Building a Real Inline IPS at Home

Building a Real Inline IPS at Home: Suricata, CrowdSec, EveBox and the Dashboards That Tie It Together

Target audience: Mid level technical readers comfortable with Linux basics, SSH, and general networking concepts (interfaces, bridges, firewalls), even if you haven’t run an IDS/IPS before.
Difficulty: Medium (expect a weekend project, plus an ongoing tuning period as false positives surface.)


Most “home network security” articles stop at a consumer router’s built-in firewall and maybe a Pi-hole for ad blocking. That’s fine as far as it goes, but it isn’t inspection; it’s filtering by list. An actual Intrusion Prevention System sits inline, looks inside every packet, and drops the bad ones in real time, the same way an enterprise security appliance functions.

This guide walks through building exactly that on a mini PC, using entirely open source tooling: Suricata as the inline packet inspection engine, CrowdSec as a collaborative, behaviour based banning layer, Fail2Ban as a backstop, and EveBox plus the CrowdSec console as the dashboards that make all of it legible day to day. Everything below is generalized from a live deployment that’s been running and being tuned for months including the false positive headaches nobody warns you about.


Why bother with inline IPS at home?

A router firewall is stateful but largely blind; it tracks connections, not content. An IPS inspects payloads against tens of thousands of signatures, correlates behaviour over time, and can drop malicious traffic before it reaches anything on your LAN, rather than after the fact in a log you check once a month. For anyone running a home lab, exposed services, or simply curious how much scanning traffic actually hits a residential IP, it’s a genuinely worthwhile weekend project. Synology routers along with certain ASUS and Ubiquiti routers have this IPS feature build it, however they do create much overhead to a router and in many cases aren’t stable nor provide the best customization/coverage.


The hardware

You don’t need a rack of enterprise gear. A small form-factor PC with a quad-port Intel NIC is enough.

Our setup:

  • Mini PC – Intel N150, quad core, fanless, running Ubuntu Server 24.04 LTS
  • 4× Intel i226-V 2.5GbE ports (the low-power, well supported Intel NIC that Linux handles natively)

One important considerations when choosing your hardware is to make sure the network chipset is Intel 225/226 as this provides the best performance for setting up the bridge needed. Avoid Realtek here and if the specs don’t mention it’s Intel it’s most likely Realtek.

Port layout used in this build:

InterfaceRole
enp1s0WAN side – connects to the ISP modem/ONT
enp2s0LAN side – connects to the main router’s WAN port
enp3s0Out of band management interface, own static IP
enp4s0Spare

The box sits physically inline between the ISP modem and the main router (a Synology RT6600ax in this case, though any router works; the IPS doesn’t care what’s downstream. Every byte in or out of the network passes through it.

Traffic flow through the inline IPS: everything crossing the WAN link passes through Suricata’s br0 bridge on the N150 before it ever reaches the router. Management access rides a completely separate interface, kept off the main data path.


Building the inline bridge

enp1s0 and enp2s0 are joined into a Linux bridge (br0) with no IP address of its own; it’s a transparent Layer 2 tap, not a router hop. Netplan handles this cleanly:

network:
  version: 2
  ethernets:
    enp1s0: {}
    enp2s0: {}
  bridges:
    br0:
      interfaces: [enp1s0, enp2s0]
      dhcp4: no
      dhcp6: no

Apply it, confirm both physical interfaces show master br0 in ip link, and you’ve got a transparent bridge that nothing upstream or downstream even notices is there — until it starts dropping traffic.

Keep a rollback copy of your original netplan config before you touch this. If the bridge comes up wrong, you lose internet for the whole house, and you’ll want the previous working file one cp away, not re-typed from memory at 11pm.

Suricata: inline IPS mode

Suricata is installed via the OISF PPA to get a current release (this build runs Suricata 8.x), then switched from its default sniffing/IDS mode into AF_PACKET inline IPS mode, where it sits between the two bridged interfaces and can actually drop packets rather than just alert on them.

The key block in /etc/suricata/suricata.yaml:

af-packet:
  - interface: enp1s0
    threads: auto
    cluster-id: 1
    cluster-type: cluster_flow
    defrag: yes
    copy-mode: ips
    copy-iface: enp2s0
  - interface: enp2s0
    threads: auto
    cluster-id: 2
    cluster-type: cluster_flow
    defrag: yes
    copy-mode: ips
    copy-iface: enp1s0

“copy-mode: ips" is the whole trick; each interface mirrors verdicts to its pair, so a drop decision on inbound traffic actually removes the packet from the outbound path instead of merely logging it.

Set HOME_NET to your actual internal range (not a single /24 if you run VLANs):

vars:
  address-groups:
    HOME_NET: "[192.168.0.0/16]"

Test before you trust it. Run in IDS mode first, watch eve.json for a few days, tune out false positives, and only then flip copy-mode to ips. Going straight to blocking mode on a home network you actually live on is how you end up locked out the internet.


Rulesets

This deployment runs a fairly aggressive multi-source ruleset stack; over 110,000 signatures loaded with zero failures combining:

  • ET Open
  • A community “aggressive” ruleset (etnetera)
  • OISF traffic-ID rules
  • Several Abuse.ch feeds: Feodo Tracker, SSL blacklist, JA3 blacklist, URLhaus

suricata-update command manages all of it. Add sources, then update and reload on a schedule:

sudo suricata-update enable-source etnetera/aggressive
sudo suricata-update
sudo suricatasc -c "reload-rules"

A daily 3am cron entry runs the update and reload automatically, so signature coverage stays current without manual intervention:

0 3 * * * /usr/bin/suricata-update && suricatasc -c "reload-rules"

IP reputation feeds

On top of signature based detection, an iprep feed adds reputation based blocking; dropping traffic from known bad netblocks regardless of what’s inside the packet. Spamhaus’s DROP/EDROP list (hijacked and rogue netblocks; a few thousand entries) is pulled daily via a small script and referenced with a one-line rule:

alert ip [$SPAMHAUS_DROP_EDROP] any -> $HOME_NET any (msg:"IPREP Spamhaus DROP/EDROP hit"; iprep:src,SpamhausDROP,isset; classtype:attempted-recon; priority:2; sid:9000014; rev:1;)

Writing your own local rules

The stock rulesets won’t catch everything you care about, and they’ll also flag things that are perfectly normal on your network. Local rules live in /etc/suricata/local.rules and go both ways. Some drop, some deliberately pass.

Drop rules for common port-scan patterns, rate-limited so a single stray SYN doesn’t trip a ban:

drop tcp any any -> any 22 (msg:"Inbound SSH probe"; flags:S; threshold:type threshold, track by_src, count 3, seconds 60; classtype:attempted-recon; priority:2; sid:9000002; rev:1;)
drop tcp any any -> any 3389 (msg:"Inbound RDP probe"; flags:S; threshold:type threshold, track by_src, count 3, seconds 60; classtype:attempted-recon; priority:2; sid:9000003; rev:1;)
drop tcp any any -> any 23 (msg:"Inbound Telnet probe"; flags:S; threshold:type threshold, track by_src, count 3, seconds 60; classtype:attempted-recon; priority:2; sid:9000001; rev:1;)

Pass rules for legitimate traffic your ISP or carrier generates that would otherwise get flagged; VoWiFi calling, mDNS discovery, a VPN endpoint you run yourself:

pass udp any 5353 -> 224.0.0.251 5353 (msg:"Pass mDNS"; sid:9000009; rev:1;)
pass tcp any any -> $HOME_NET 443 (msg:"Pass VPN service inbound"; sid:9000012; rev:1;)

A few hard won gotchas worth flagging before you hit them yourself:

  • Rule and iprep files must be owned root:suricata, not root:root, or Suricata silently fails to load them on some setups.
  • Inbound drop rules must target any, not $HOME_NET, as the destination – your public WAN facing IP isn’t inside HOME_NET, so a rule written the “obvious” way simply never matches.
  • Never hardcode your own WAN IP anywhere in rules or config. If your ISP assigns dynamic addresses (most residential connections do), a hardcoded IP becomes stale garbage the day it changes, or worse, ends up publishing your address if you ever share the config.
  • On Suricata 8.x, JA3 fingerprint rules use the ja3.hash keyword.

Taming false positives (the part nobody tells you about)

This is the least glamorous and most time consuming part of running an IPS, and it never fully stops. A few patterns that show up in almost every home deployment:

  • Cloud-connected IoT devices (cameras, doorbells, smart plugs) routinely maintain long-lived TLS “keepalive” tunnels to vendor cloud relays, even with cloud recording disabled. These can trip rules like “large upload over TLS” purely because the tunnel accumulates bytes over 24 hours. If the vendor’s relay infrastructure spans multiple cloud providers and rotates IPs on a short DNS TTL, per-destination suppression is a losing game; global suppression of that specific signature ID is usually the only sane fix.
  • Suppressing by source IP doesn’t work for outbound false positives on an inline bridge: Suricata sees the post-NAT WAN address as the source for every outbound flow, not the originating LAN device’s actual IP. Suppress by destination instead, or globally if the destination pool is too large to enumerate.
  • Carrier specific traffic (VoWiFi calling, carrier VPN handshakes) will alert until you write explicit pass rules or suppress entries for it.

All suppression lives in /etc/suricata/threshold.config (note: .config, not .conf):

suppress gen_id 1, sig_id 3306862
suppress gen_id 1, sig_id 3300244, track by_dst, ip 198.51.100.0/24

Expect this file to grow steadily over the first month or two of operation, then stabilise.


CrowdSec: behavioural banning at scale

Where Suricata inspects packets against known signatures, CrowdSec takes a different, complementary approach: it parses logs (Suricata’s alert stream, auth.log, syslog) for behavioural patterns; repeated auth failures, distributed scan attempts, known bad actors reported by the wider CrowdSec community and issues time limited bans enforced at the firewall layer via a bouncer.

Install CrowdSec and the nftables bouncer:

curl -s https://install.crowdsec.net | sudo sh
sudo apt install crowdsec crowdsec-firewall-bouncer-nftables

Feeding it Suricata’s alerts

An acquisition file tells CrowdSec where to read Suricata’s eve.json:

# /etc/crowdsec/acquis.d/setup.suricata.yaml
filenames:
  - /var/log/suricata/eve.json
labels:
  type: suricata

Install the Suricata collection so CrowdSec knows how to interpret those alerts:

sudo cscli collections install crowdsecurity/suricata
sudo cscli collections install crowdsecurity/sshd
sudo cscli collections install crowdsecurity/linux
sudo cscli collections install crowdsecurity/whitelist-good-actors

One important tuning step: the stock suricata-alerts scenario only sets remediation: true on severity-1 alerts by default, meaning severity-2 alerts (which is where most of the custom local drop rules land, by design) get logged but never actually trigger a ban. Adding remediation: true to the severity-2 scenario as well closes that gap. But be aware that a cscli hub upgrade will overwrite the scenario file and silently revert this. A small idempotent fix script, run from cron right after the weekly hub upgrade, re-applies the setting automatically so it never gets missed:

#!/bin/bash
# crowdsec-fix-suricata.sh — restore remediation:true after hub upgrades
SCENARIO=/etc/crowdsec/scenarios/suricata-alerts.yaml
if ! grep -q "remediation: true" "$SCENARIO"; then
    sed -i '/severity: 2/a\  remediation: true' "$SCENARIO"
    systemctl restart crowdsec
    echo "$(date): remediation:true restored" >> /var/log/crowdsec-fix.log
fi

Ban profile and enforcement

/etc/crowdsec/profiles.yaml controls how long a ban lasts once triggered. A 4 hour ban is a reasonable middle ground: long enough to meaningfully disrupt a scanning script, short enough that a genuine false positive doesn’t lock out a real user or device for a whole day.

The nftables bouncer enforces every decision CrowdSec makes at the packet-filter level, independent of whatever Suricata itself is doing which matters, because these are two separate, independently-failing layers. If Suricata stops, CrowdSec keeps enforcing existing bans and the shared community blocklist (CAPI), but loses its richest source of new detections. If CrowdSec stops, Suricata keeps dropping packets per its own rules, but nothing gets banned network-wide based on behaviour. Understanding this separation matters when something in the chain misbehaves at 2am and you need to know what’s still actually protecting you.

The community blocklist

Enrolling the local CrowdSec instance with the CrowdSec console (cscli console enroll) opts into the Community Alert Protection Interface (CAPI) a shared blocklist assembled from bans triggered across the whole CrowdSec user base. It auto-updates with no cron job needed, and it means the box benefits from other users’ scan/attack observations, not just its own.

Fail2Ban: the backstop layer

With Suricata dropping most inbound SSH probes before they ever reach sshd, Fail2Ban’s classic auth-log-based detection becomes a thinner, secondary layer rather than the primary defence, but it’s still worth running as a backstop for anything that slips past the inline inspection, and for local/LAN-originated abuse that never crosses the bridge at all. Standard jails cover it:

[sshd]
enabled = true
[sshd-ddos]
enabled = true
[recidive]
enabled = true
bantime = 604800

The dashboards: making sense of the firehose

An IPS that drops thousands of packets a day is worthless if you can’t see what it’s dropping and why. Two dashboards cover this from complementary angles.

EveBox – the packet-level view

EveBox is a purpose-built web UI for Suricata’s eve.json alert stream: think of it as Kibana, but scoped specifically to Suricata and far lighter to run.

Install from the official repo:

sudo mkdir -p /etc/apt/keyrings
curl -fsSL https://evebox.org/files/GPG-KEY-evebox | sudo tee /etc/apt/keyrings/evebox.asc
echo "deb [signed-by=/etc/apt/keyrings/evebox.asc] https://evebox.org/files/debian stable main" | sudo tee /etc/apt/sources.list.d/evebox.list
sudo apt update && sudo apt install evebox

(Use the newer .asc-keyring format with signed-by, not the older standalone .gpg keyring approach – the old method is deprecated and will throw GPG errors on apt update if you copy an outdated guide.)

Point it at the alert log and bind it to your management interface only; there’s no reason to expose this beyond your own LAN:

sudo evebox oneshot --input /var/log/suricata/eve.json --host 192.0.2.10 --port 5636

Once running, EveBox gives you:

  • A live, filterable feed of every alert, grouped by signature + source + destination (it deduplicates repeated hits into a single row with a count, which is why its numbers look much lower than CrowdSec’s raw event count for the same underlying traffic; both are correct, they’re just different presentation models)
  • One click “add to threshold config” for suppressing a noisy signature straight from the UI
  • Full packet payload inspection for any given alert
  • Time-range filtering and basic reporting

This is where you’ll spend most of your tuning time. It’s the fastest way to spot a new false positive pattern and decide whether it needs a pass rule, a suppress entry, or is a real hit worth investigating.

The CrowdSec console – the behavioural view

Separately, the CrowdSec web console (app.crowdsec.net, free tier) gives a higher-level view once your engine is enrolled:

sudo cscli console enroll <your-enroll-key>

It shows active bans, the scenarios that triggered them, a map of where inbound scan/attack traffic is geographically originating, and because of the shared CAPI blocklist some sense of how your exposure compares to the wider community baseline. It’s less about individual packets and more “is my network currently under any kind of sustained pressure, and from where.”

Two health check commands worth memorising and running from a simple daily script or alias:

# CrowdSec: current bans and recent alerts
sudo cscli decisions list
sudo cscli alerts list

# Suricata: rule load status and signature count
sudo grep "signatures processed\|rules failed" /var/log/suricata/suricata.log | tail -3

If a decision list ever shows your own WAN IP banned (it happens, a false positive from your own outbound traffic is entirely possible), removing it is a single command:

sudo cscli decisions delete --ip <your-wan-ip>


Don’t forget log rotation

This is the boring bit that will bite you if skipped. Suricata’s eve.json is a single, continuously appended file; it is not date-partitioned, and without a logrotate policy it grows without bound. Left unmanaged, a busy inline IPS can produce a multi-gigabyte alert log within weeks, quietly eating disk space until something else on the box fails first.

# /etc/logrotate.d/suricata
/var/log/suricata/eve.json
/var/log/suricata/fast.log
/var/log/suricata/stats.log
{
    daily
    rotate 20
    missingok
    notifempty
    compress
    delaycompress
    copytruncate
    su suricata suricata
}

copytruncate is the important detail here, not the default create/postrotate pattern. Suricata holds eve.json open continuously via its file descriptor, so copying the file and truncating it in place avoids needing to signal or restart the process to pick up a fresh log file. The trade-off is that copytruncate needs enough free disk headroom to hold a full copy of the log before it can truncate. If you’re setting this up retroactively on a log that’s already ballooned unmanaged for months, truncate it manually first (sudo truncate -s 0 /var/log/suricata/eve.json, accepting the loss of historical alert data) and let the new rotation policy manage it cleanly from a small starting size going forward.

Verify the policy is correctly configured with a dry run before trusting it:

sudo logrotate -d /etc/logrotate.d/suricata

Putting it all together

The finished enforcement chain looks like this:

Internet → WAN interface → nftables (CrowdSec bans + community blocklist)
         → Suricata inline inspection (signature + iprep drops)
         → LAN interface → Router → rest of network

Three independent layers, each covering a different failure mode:

  1. CrowdSec / nftables – persistent, behaviour-triggered IP bans, plus a shared community blocklist that benefits from other users’ observations
  2. Suricata – real-time, inline packet-level inspection and drops against signature and reputation rules
  3. Fail2Ban – a thinner backstop specifically for auth-log-based abuse that slips past the other two

None of this is “set and forget”, expect an ongoing rhythm of checking EveBox for new false-positive patterns, occasionally tuning a suppress rule, and letting the automated update cron jobs keep signatures and community intelligence current. But once it’s tuned, it runs quietly in the background, doing real inline inspection on every byte that crosses your connection, something no consumer router firewall attempts.

Leave a Reply

Your email address will not be published. Required fields are marked *