OpenWrt is an open source Linux project aimed at embedded devices to route network traffic (e.g. routers). I’ve consistently run OpenWrt on my home routers for over a decade now (I still remember the brief LEDE split), and it has since been my preferred home router OS. While I’ve also wanted to experiment with OPNsense (and pfSense before), I’ve never had a real reason to thus far, but I digress…​

It might be interesting to add some network security such as intrusion prevention to your residential gateway directly. You might of old be familiar with Fail2Ban, and I’ve happily used Fail2Ban for years. CrowdSec is a similar solution, albeit more community-driven. Klaus Agnoletti, then (still?) head of community at CrowdSec, summarised the similarities and differences between the two:

  • Both Fail2Ban and CrowdSec both read service logs to detect attacks, and blocks those offending IP addresses accordingly;

  • CrowdSec does share this information with the community (only offending IP address, and attack type), so other CrowdSec users (and vice versa) will benefit and block those IPs as well;

  • CrowdSec however claims it can detect and block more advanced attacks such as various forms of (D)DoS attacks, or layer 7 attacks (XSS, SQLi, etc.) using specialised application bouncers (e.g. Nginx, WordPress bouncers).

ℹ️

Fail2Ban is still fine for most use cases on your home router. CrowdSec is a more obvious choice if you, for example, expose some services on your router directly to the Internet, such as:

  • SSH server (although best behind a VPN, using keys only);

  • A (very) small web server / reverse proxy;

  • An ad-blocking DNS resolver; etc.

But even if you don’t, CrowdSec still offers nice frequently updated community (and premium) block lists, which might be worth it alone.

Although OpenWrt also supports x86(-64) systems, it is typically run on embedded devices with (very) little storage (e.g. ~8-32 MiBs). My current home router has 32 MiB storage, and I could still afford installing the crowdsec-firewall-bouncer (~5 MiB), but not the full Security Engine (~60 MiB). I therefore decided to run the Security Engine on my server (Docker container), and connect it to the bouncer on my OpenWrt router. This is roughly what it looks like (PlantUML made a bit of a mess, sorry…​):

overview
ℹ️
Syslog messages are not encrypted by itself this way. Consider encapsulating this traffic using an encrypted tunnel (e.g. WireGuard).

Setup

CrowdSec offers a guide to run the Security Engine on Docker, which I’ve slightly tweaked to also expose the Syslog server it offers:

services:
  crowdsec:
    image: crowdsecurity/crowdsec
    restart: always
    ports:
      - 192.168.1.2:8080:8080/tcp
      - 192.168.1.2:514:514/udp
    environment:
      COLLECTIONS: crowdsecurity/iptables
      GID: ${GID-1000}
    volumes:
      - crowdsec-db:/var/lib/crowdsec/data/
      - crowdsec-config:/etc/crowdsec/
      - ./crowdsec/acquis.d:/etc/crowdsec/acquis.d/
volumes:
  crowdsec-db: null
  crowdsec-config: null
networks: {}

In the crowdsec-config Docker volume, expose a syslog server to facilitate ingesting logs from your OpenWrt router, in the acquis.d directory. For example, in acquis.d/openwrt.yaml:

source: syslog
listen_addr: 0.0.0.0
listen_port: 514
labels:
  type: syslog

Once the Security Engine has started, enroll it to your CrowdSec account. Replace with your own key (log in to CrowdSec, and click on the Enroll command button to show your key):

user $ docker exec <container-name> cscli console enroll -n <engine-name-of-your-choosing> abcdefghijklmnopqrstuvwxy

Then confirm the enrollment on the web interface.

Add a new bouncer on the Security Engine for your OpenWrt router:

user $ docker exec <container-name> cscli console bouncers add openwrt
API key for 'openwrt':

   abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQ

Please keep this key since you will not be able to retrieve it!

Next, on the OpenWrt system, install the bouncer (adds ~5 MiB):

root # opkg install crowdsec-firewall-bouncer

After installation, configure the bouncer:

root # uci set crowdsec.@bouncer[0].enabled=1
root # uci set crowdsec.@bouncer[0].api_url='http://192.168.1.2:8080/'
root # uci set crowdsec.@bouncer[0].api_key-'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQ'
root # uci del_list crowdsec.@bouncer[0]='eth1'       # if this is not your wan interface
root # uci add_list crowdsec.@bouncer[0]='pppoe-wan'  # if any of these are your wan interface(s)
root # uci add_list crowdsec.@bouncer[0]='br-lan'
root # uci add_list crowdsec.@bouncer[0]='wg0'

Or manually update /etc/config/crowdsec accordingly.

Restart the CrowdSec bouncer after making your changes:

root # /etc/init.d/crowdsec-firewall-bouncer restart
root # /etc/init.d/crowdsec-firewall-bouncer status
running

Next, have OpenWrt forward its logs to the CrowdSec syslog server:

root # uci set system.@system[0].log_ip='192.168.1.2'
root # uci set system.@system[0].log_port='514'
root # uci set system.@system[0].log_proto='udp'

Or manually update /etc/config/system accordingly.

Back on the Docker system, inspect if the bouncer is properly added:

user $ docker exec <container-name> cscli bouncers list
--------------------------------------------------------------------------------------------------
 Name     IP Address   Valid  Last API pull         Type                       Version  Auth Type
--------------------------------------------------------------------------------------------------
 openwrt  192.168.1.1  ✔️     2025-10-29T08:34:23Z  crowdsec-firewall-bouncer           api-key
--------------------------------------------------------------------------------------------------

You should now have a working CrowdSec setup protecting your OpenWrt router using Syslog.

Verification / Troubleshooting

Click to expand/collapse

You can inspect if your container gets your OpenWrt syslog messages by checking with tcpdump. For example, try (re)logging into OpenWrt via SSH which should trigger a few syslog messages being sent:

root # tcpdump -A -i eth0 port 514
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on eth0, link-type RAW (Raw IP), snapshot length 262144 bytes
16:56:10.758789 IP 192.168.1.1.51542 > 192.168.1.2.514: UDP, length 105
E.....@.@..:.......2.V...q..<86>Oct 30 16:56:10 openwrt.lan dropbear[3113]: Exit (root) from <192.168.1.12:44400>: Disconnect received
16:56:14.244632 IP 192.168.1.1.51542 > 192.168.1.2.514: UDP, length 87
E..s..@.@..K.......2.V..._1.<86>Oct 30 16:56:14 openwrt.lan dropbear[3644]: Child connection from 192.168.1.12:58226
16:56:14.553539 IP 192.168.1.1.51542 > 192.168.1.2.514: UDP, length 175
E.....@.@..........2.V.....O<85>Oct 30 16:56:14 openwrt.lan dropbear[3644]: Pubkey auth succeeded for 'root' with ssh-ed25519 key SHA256:abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQ from 192.168.1.12:58226

These should correspond with the syslog messages you can inspect via logread on the OpenWrt console:

root # logread
[...]
Thu Oct 30 16:54:46 2025 daemon.info dnsmasq-dhcp[1]: DHCPREQUEST(br-lan) 192.168.1.20 aa:bb:cc:dd:ee:ff
Thu Oct 30 16:54:46 2025 daemon.info dnsmasq-dhcp[1]: DHCPACK(br-lan) 192.168.1.20 aa:bb:cc:dd:ee:ff HOSTNAME
Thu Oct 30 16:56:10 2025 authpriv.info dropbear[3113]: Exit (root) from <192.168.1.12:44400>: Disconnect received
Thu Oct 30 16:56:14 2025 authpriv.info dropbear[3644]: Child connection from 192.168.1.12:58226
Thu Oct 30 16:56:14 2025 authpriv.notice dropbear[3644]: Pubkey auth succeeded for 'root' with ssh-ed25519 key SHA256:abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQ from 192.168.1.12:58226

Also verify if the CrowdSec container correctly handles incoming messages:

user $ docker exec <container-name> cscli metrics
+--------------------------------------------------------------------------------------------------------------+
| Acquisition Metrics                                                                                          |
+--------------------+------------+--------------+----------------+------------------------+-------------------+
| Source             | Lines read | Lines parsed | Lines unparsed | Lines poured to bucket | Lines whitelisted |
+--------------------+------------+--------------+----------------+------------------------+-------------------+
| syslog:192.168.1.1 | 344        | -            | 344            | -                      | -                 |
+--------------------+------------+--------------+----------------+------------------------+-------------------+
+---------------------------------------------------------------------------------------+
| Bouncer Metrics (openwrt) since 2025-10-28 20:32:04 +0000 UTC                         |
+----------------------------+------------------+-------------------+-------------------+
| Origin                     | active_decisions |      dropped      |     processed     |
|                            |        IPs       |  bytes  | packets |  bytes  | packets |
+----------------------------+------------------+---------+---------+---------+---------+
| CAPI (community blocklist) |           16.36k | 176.10k |   2.43k |       - |       - |
| lists:firehol_botscout_7d  |            1.84k |   9.90k |     100 |       - |       - |
| lists:firehol_cybercrime   |              168 |       0 |       0 |       - |       - |
| lists:firehol_greensnow    |            2.12k |  27.67k |     475 |       - |       - |
+----------------------------+------------------+---------+---------+---------+---------+
|                      Total |           20.48k | 213.67k |   3.00k | 111.97M | 823.63k |
+----------------------------+------------------+---------+---------+---------+---------+
+-----------------------------------------------+
| Local API Decisions                           |
+---------------------+--------+--------+-------+
| Reason              | Origin | Action | Count |
+---------------------+--------+--------+-------+
| firehol_cybercrime  | lists  | ban    | 174   |
| firehol_greensnow   | lists  | ban    | 6322  |
| generic:scan        | CAPI   | ban    | 2509  |
| ssh:bruteforce      | CAPI   | ban    | 11124 |
| ssh:exploit         | CAPI   | ban    | 273   |
| tcp:scan            | CAPI   | ban    | 2535  |
| firehol_botscout_7d | lists  | ban    | 2132  |
+---------------------+--------+--------+-------+
+--------------------------------------+
| Local API Metrics                    |
+----------------------+--------+------+
| Route                | Method | Hits |
+----------------------+--------+------+
| /v1/decisions/stream | GET    | 5897 |
| /v1/heartbeat        | GET    | 982  |
| /v1/usage-metrics    | POST   | 99   |
| /v1/watchers/login   | POST   | 17   |
+----------------------+--------+------+
+------------------------------------------------+
| Local API Bouncers Metrics                     |
+---------+----------------------+--------+------+
| Bouncer | Route                | Method | Hits |
+---------+----------------------+--------+------+
| openwrt | /v1/decisions/stream | GET    | 5897 |
+---------+----------------------+--------+------+
+-------------------------------------------+
| Local API Machines Metrics                |
+-----------+---------------+--------+------+
| Machine   | Route         | Method | Hits |
+-----------+---------------+--------+------+
| localhost | /v1/heartbeat | GET    | 982  |
+-----------+---------------+--------+------+
+------------------------------------------------------------+
| Parser Metrics                                             |
+---------------------------------+------+--------+----------+
| Parsers                         | Hits | Parsed | Unparsed |
+---------------------------------+------+--------+----------+
| child-crowdsecurity/syslog-logs | 344  | 344    | -        |
| crowdsecurity/syslog-logs       | 344  | 344    | -        |
+---------------------------------+------+--------+----------+

You can also check if nftables rules are being updated on OpenWrt:

root # nft list table ip crowdsec
table ip crowdsec {
        set crowdsec-blacklists {
                type ipv4_addr
                flags timeout
        }

        set crowdsec-blacklists-CAPI {
                type ipv4_addr
                flags timeout
                elements = { 1.12.217.80 timeout 6d8h6m36s expires 4d13h56m44s390ms, 1.13.5.88 timeout 4d2h6m36s expires 2d7h56m44s240ms,
                             1.13.19.219 timeout 6d8h6m36s expires 4d13h56m43s970ms, 1.13.79.144 timeout 6d8h6m36s expires 4d13h56m44s240ms,
                             1.13.162.201 timeout 4d4h6m36s expires 2d9h56m44s140ms, 1.14.12.141 timeout 6d19h6m36s expires 5d56m44s260ms,
                             1.15.148.9 timeout 6d8h6m36s expires 4d13h56m44s300ms, 1.24.210.27 timeout 5d18h6m36s expires 3d23h56m44s170ms,
                             1.25.18.18 timeout 4d7h6m36s expires 2d12h56m44s200ms, 1.30.20.98 timeout 4d8h6m36s expires 2d13h56m44s320ms,
                             1.30.20.238 timeout 6d18h6m36s expires 4d23h56m44s20ms, 1.31.80.222 timeout 4d15h6m36s expires 2d20h56m43s990ms,
                             1.34.51.163 timeout 6d16h6m36s expires 4d21h56m43s800ms, 1.55.33.86 timeout 6d21h6m36s expires 5d2h56m44s,
[...]

And for IPv6 addresses:

root # nft list table ip6 crowdsec6
table ip6 crowdsec6 {
        set crowdsec6-blacklists {
                type ipv6_addr
                flags timeout
        }

        set crowdsec6-blacklists-CAPI {
                type ipv6_addr
                flags timeout
                elements = { 2001:470:2cc:1::1bf timeout 6d21h59m56s expires 5d22h21m43s580ms,
                             2001:470:2cc:1::1df timeout 1d22h59m56s expires 21h21m43s230ms,
                             2001:470:2cc:1::1ff timeout 4d19h6m36s expires 2d23h17m23s850ms,
                             2001:470:2cc:1::29f timeout 2d5h6m36s expires 9h17m23s850ms,
                             2001:67c:89c:666::1 timeout 6d22h6m36s expires 5d2h17m23s850ms,
                             2602:80d:1000::10 timeout 6d19h6m36s expires 4d23h17m23s850ms,
                             2602:80d:1000::11 timeout 6d17h6m36s expires 4d21h17m23s850ms,
                             2602:80d:1000::12 timeout 6d21h6m36s expires 5d1h17m23s850ms,
                             2602:80d:1000::13 timeout 6d21h6m36s expires 5d1h17m23s850ms,
                             2602:80d:1000::14 timeout 6d22h6m36s expires 5d2h17m23s850ms,
                             2602:80d:1000::15 timeout 6d22h6m36s expires 5d2h17m23s850ms,
[...]