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.
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.
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.
┌──────────────────────────── 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.
bash scripts/install-on-firewalla.sh 192.168.1.1Builds 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# 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).
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.
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/crowdsecfrom/staging/etc/crowdsecon first boot. Bind-mounting our empty/data/crowdsec/configover/etc/crowdsecshadows that seed. - BusyBox
cp -rn /staging/etc/crowdsec/. /etc/crowdsec/doesn't expand the.the way GNUcpdoes — copies nothing. cscli capi registerneeds the CAPI config section already present inconfig.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 -ctriggers SIGPIPE underset -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:
- Generate a bouncer API key (openssl, not a pipe).
- Export it as
BOUNCER_KEY_firewalla_bouncer. bash /docker_start.sh &— let upstream do everything it knows how.- Poll
/healthuntil the Engine's LAPI is up. - Start
crowdsec-firewall-bouncerin the foreground. - Exit when either child dies (Docker's
restart: alwayscycles us).
All the edge cases above are upstream's problem now.
Three overlapping mechanisms:
restart: alwaysin compose — Docker daemon auto-restarts the container after reboot.post_main.d/start_crowdsec.sh— Firewalla's post-init hook runsdocker-compose up -das a belt-and-suspenders, surviving both reboots and firmware upgrades.- Bouncer reinstalls
CROWDSEC_CHAIN+ repopulatescrowdsec-blacklists-0from 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.
- 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.
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.
Alerts — notifications/http.yaml is stubbed out. Set
CROWDSEC_WEBHOOK_URL in compose (e.g. an ntfy.sh URL) to activate.
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.
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
MIT.