Skip to content

Zeerg/crowdsec-firewalla

Repository files navigation

crowdsec-firewalla

CrowdSec Engine + iptables firewall bouncer in a single Docker container, tuned to run on a Firewalla Gold without disturbing any of Firewalla's own filter chains. Built because the stock MSP target-list API caps blocklists at ~9k IPs, and that wasn't enough.

Why bother

Firewalla ships with its own threat-intel pipeline (the closed-source intelproxy daemon reading bloom filters from /home/pi/.firewalla/run/category_data/filters/). That's great for domain-level blocking. But for raw-IP blocklists — the kind you'd typically push from Emerging Threats, IPsum, abuse.ch — you go through the MSP REST API, which quietly caps target lists at roughly 9k entries.

Under the hood, the Linux ipsets those target lists are backed by are hash:net maxelem 65536. 9k is not a kernel limit. It's an API throttle. And even 65k felt stingy once the plan was "pull everything the community is seeing."

CrowdSec runs its own ipset + iptables chain, independent of Firewalla's, with maxelem 524288 here (512k). The community blocklist alone seeds 15k IPs within seconds of enrolment, and signals from ~100k other installations keep the list fresh. You also get local behavioural detection (SSH brute force, iptables scan scenarios) that pull-only feeds can't do — a static blocklist can't notice someone probing you specifically.

What you end up with

On a fresh deploy, within about a minute:

Chain INPUT (policy ACCEPT)
num   target
1     CROWDSEC_CHAIN       ← we insert this; drops on ipset hit
2     FR_INPUT             ← Firewalla's FireRouter chain, untouched
3     FW_INPUT_ACCEPT      ← Firewalla's own, untouched
4     FW_INPUT_DROP        ← Firewalla's own, untouched

Chain CROWDSEC_CHAIN
target     source          match
DROP       0.0.0.0/0       match-set crowdsec-blacklists-0 src

Position 1 is deliberate. Known-bad traffic never reaches Firewalla's own logging — quieter event log, lower CPU on the FW chains. If Firewalla ever flushes INPUT mid-boot, the bouncer notices within ~10s and re-inserts.

The mirror set-up exists on FORWARD so LAN clients reaching out to known-C2 IPs are dropped at the gateway.

Architecture at a glance

┌──────────────────────────── firewalla container (our image) ─────────────┐
│                                                                           │
│  /docker_start.sh (upstream) ──┬── cscli machines add (self-register)    │
│                                ├── cscli capi register (community feed)   │
│                                ├── cscli bouncers add $BOUNCER_KEY        │
│                                └── exec crowdsec (Engine, LAPI :8080)     │
│                                                                           │
│  firewalla-entrypoint.sh ──────── waits for /health ────▶ starts bouncer  │
│                                                                           │
│  crowdsec-firewall-bouncer ────── ipset + iptables on host netns          │
└───────────────────────────────────────────────────────────────────────────┘
         ▲                                                       ▲
   host network_mode                                        CAP_NET_ADMIN
         │                                                       │
         ▼                                                       ▼
   Firewalla host: /log/system/*, iptables filter table, conntrack

Single container. Host networking (so iptables writes land on the Firewalla's netns, not a container-local one). CAP_NET_ADMIN + CAP_NET_RAW. /:/host:ro so the Engine can parse logs.

All CrowdSec state lives under /data/crowdsec/ — SQLite DB, machine/bouncer/CAPI credentials, console enrolment, downloaded hub content. Survives container restarts; survives image rebuilds; survives firmware upgrades via post_main.d/start_crowdsec.sh.

Deploy

bash scripts/install-on-firewalla.sh 192.168.1.1

Builds the image locally (linux/amd64 via buildx), docker saves it, scps to the Firewalla, loads, and docker-compose up -d.

First run is a bit chatty: upstream's entrypoint does the machine registration + CAPI enrolment dance, our wrapper generates a bouncer API key and hands it to upstream via BOUNCER_KEY_firewalla_bouncer=…. On restarts the key persists in the bind-mounted bouncer.yaml, so no re-registration.

Optional: enrol the CrowdSec Console (web UI at https://app.crowdsec.net) after the container is up — one command, persists in /data/:

ssh pi@192.168.1.1 'sudo docker exec crowdsec cscli console enroll YOUR_ENROLL_KEY'
sudo docker restart crowdsec

Verify

# Engine version
ssh pi@192.168.1.1 'sudo docker exec crowdsec crowdsec --version'

# Is the chain in place?
ssh pi@192.168.1.1 'sudo iptables -L INPUT -v -n --line-numbers | head'

# Is the ipset populated?
ssh pi@192.168.1.1 'sudo ipset list crowdsec-blacklists-0 | grep "^Number of entries"'

# Sample decisions from CAPI
ssh pi@192.168.1.1 'sudo docker exec crowdsec cscli decisions list --all | head'

# Live log tail
ssh pi@192.168.1.1 'sudo docker logs -f crowdsec'

Expect ~15k entries in the set within 60s of first deploy, growing as CAPI refreshes (every ~2h).

Design notes (the interesting parts)

Why single-container when upstream says to put the bouncer on the host

The CrowdSec docs recommend: Engine in Docker, cs-firewall-bouncer apt-installed on the host. That's sensible for general Linux, but Firewalla is a managed box — adding packages to the host risks getting clobbered on firmware updates. Bundling both processes into one image with NET_ADMIN + host networking gets the same effect without touching the host filesystem outside /data.

Why we delegate setup to upstream's docker_start.sh

First iteration of this tried to do all the bootstrap ourselves: cscli machines add, cscli capi register, cscli bouncers add, replace the placeholder API key in bouncer.yaml, etc. Every edge case surfaced the hard way:

  • Upstream seeds /etc/crowdsec from /staging/etc/crowdsec on first boot. Bind-mounting our empty /data/crowdsec/config over /etc/crowdsec shadows that seed.
  • BusyBox cp -rn /staging/etc/crowdsec/. /etc/crowdsec/ doesn't expand the . the way GNU cp does — copies nothing.
  • cscli capi register needs the CAPI config section already present in config.yaml — which upstream injects dynamically, not in the baked-in template.
  • The bouncer "already registered" check had to distinguish an actual API key from the placeholder CHANGEME.
  • tr -dc | head -c triggers SIGPIPE under set -o pipefail, killing the entrypoint silently (zero log output, rc 141 — fun to debug).
  • Upstream reads BOUNCER_KEY_<name>=<value> env vars, but shell env var names can't contain hyphens.

Rather than re-solve each, v2 of the entrypoint is a thin shim:

  1. Generate a bouncer API key (openssl, not a pipe).
  2. Export it as BOUNCER_KEY_firewalla_bouncer.
  3. bash /docker_start.sh & — let upstream do everything it knows how.
  4. Poll /health until the Engine's LAPI is up.
  5. Start crowdsec-firewall-bouncer in the foreground.
  6. Exit when either child dies (Docker's restart: always cycles us).

All the edge cases above are upstream's problem now.

Persistence across reboots

Three overlapping mechanisms:

  • restart: always in compose — Docker daemon auto-restarts the container after reboot.
  • post_main.d/start_crowdsec.sh — Firewalla's post-init hook runs docker-compose up -d as a belt-and-suspenders, surviving both reboots and firmware upgrades.
  • Bouncer reinstalls CROWDSEC_CHAIN + repopulates crowdsec-blacklists-0 from its SQLite DB on every startup. ipset and iptables aren't kernel-persistent, but the bouncer has a ~10s rule-health loop that re-inserts anything missing.

Interop

  • Firewalla's FW_* chains — untouched. They run after us; our chain is strictly additive to INPUT/FORWARD at position 1.
  • Firewalla's own Target Lists — zero conflict. Target Lists live in separate ipsets that Firewalla manages via the MSP API; we write to our own ipset. An IP in both lists is dropped twice (same packet path, first match wins in the kernel). Harmless.

CrowdSec is meant to sit alongside Firewalla's native blocking, not replace it. Firewalla's Target List blocks show up in the app UI; CrowdSec's blocks are invisible to that UI. Running both gets you the union.

Tuning knobs

Collections — edit COLLECTIONS= in docker-compose.yml:

COLLECTIONS=crowdsecurity/linux crowdsecurity/sshd crowdsecurity/iptables

Browse hub.crowdsec.net and add what applies. Not all parsers have a matching log source on a Firewalla.

Log sources — add more YAML docs to acquis.d/firewalla.yaml. See the comment block in that file for the symlink trap to avoid.

Deny action — defaults to DROP. To do a dry run (observe what would be blocked without actually blocking), flip deny_action: LOG in cs-firewall-bouncer.yaml and rebuild.

Alertsnotifications/http.yaml is stubbed out. Set CROWDSEC_WEBHOOK_URL in compose (e.g. an ntfy.sh URL) to activate.

Rollback

ssh pi@192.168.1.1 '
  cd /data/crowdsec/compose && sudo docker-compose down
  for t in INPUT FORWARD; do
    sudo iptables  -D "$t" -j CROWDSEC_CHAIN 2>/dev/null || true
    sudo ip6tables -D "$t" -j CROWDSEC_CHAIN 2>/dev/null || true
  done
  sudo iptables  -F CROWDSEC_CHAIN 2>/dev/null; sudo iptables  -X CROWDSEC_CHAIN 2>/dev/null
  sudo ip6tables -F CROWDSEC_CHAIN 2>/dev/null; sudo ip6tables -X CROWDSEC_CHAIN 2>/dev/null
  sudo ipset destroy crowdsec-blacklists-0 2>/dev/null
  sudo ipset destroy crowdsec6-blacklists-0 2>/dev/null
  sudo rm -f /home/pi/.firewalla/config/post_main.d/start_crowdsec.sh
'

State under /data/crowdsec/ is preserved unless you explicitly rm -rf it — so you can reinstall later with the same bouncer ID and console enrolment intact.

Layout

Dockerfile                 CrowdSec engine + firewall bouncer, seeded
entrypoint.sh              Thin shim over upstream's docker_start.sh
cs-firewall-bouncer.yaml   Bouncer config (chains, deny action, ipset size)
acquis.d/firewalla.yaml    Log sources to parse (Firewalla-specific paths)
notifications/http.yaml    Stub HTTP notification plugin
docker-compose.yml         The service
post_main.d/               Boot-survival shim
scripts/install-on-firewalla.sh   Build + ship + restart

License

MIT.

About

CrowdSec Engine + iptables firewall bouncer for Firewalla Gold (Docker)

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors