selfhostedops-stack/crowdsec/README.md
Maximilian Grimm 88670f028f Episode 12/13: CrowdSec next to fail2ban, and an AppSec WAF in Caddy
Measured on the demo box against the same 24h of real traffic.

Episode 12 (detection, blocklist, firewall):
- crowdsec/ with install notes and the traps: port 8080 clash, empty first
  blocklist pull, replay forensic-mode dropping events (FIFO workaround),
  ssh-time-based-bf shipping remediation:false, the AppSec collections that
  refuse to start without extra rules.
- firewall bouncer in nftables mode (input + forward), which blocks
  Docker-published ports and survives ufw reload; iptables/DOCKER-USER does not.
- HARDENING.md episode 12 section with the numbers: fail2ban 4 banned IPs vs
  CrowdSec 23 detected / 1 blocked, blocklist overlap 50% of IPs but 19% of
  attempts, and the mode=normal blind spot for key-only root attempts.

Episode 13 (AppSec WAF):
- caddy/Dockerfile now builds the CrowdSec bouncer and AppSec handler alongside
  rate-limit, pinned to v0.14.1.
- Caddyfile imports a crowdsec snippet (bouncer + appsec) on every site except
  vpn; compose passes CROWDSEC_BOUNCER_KEY and host.docker.internal.
- Measured: virtual patching blocks known CVEs only (7 of 41 probes, near-zero
  false positives); OWASP CRS raises that to 27 of 41 but breaks git push and
  wiki editing at default paranoia. Default stays vpatch+generic.

fail2ban config unchanged. No secrets or real domains/IPs in tracked files.
2026-09-16 20:34:18 +02:00

5 KiB

crowdsec/ (episode 12)

CrowdSec next to fail2ban on the same box, both reading the same logs. This folder holds the files that differ from a stock install. Everything was measured on the demo box; the numbers are in HARDENING.md (episode 12).

CrowdSec runs on the host, not in the stack: it reads the sshd journal directly and drives the host firewall. Caddy talks to it over the Docker bridge.

Install (Debian 13)

curl -fsSL https://install.crowdsec.net | sh
apt install crowdsec

Port 8080 trap. The Local API defaults to 127.0.0.1:8080. On this stack the NetBird dashboard already publishes 127.0.0.1:8080, so the installer prints one line ("Not attempting to start crowdsec, port 8080 is already used") and leaves the service inactive. Move it:

sed -i 's#listen_uri: 127.0.0.1:8080#listen_uri: 0.0.0.0:8090#' /etc/crowdsec/config.yaml
sed -i 's#url: http://127.0.0.1:8080#url: http://127.0.0.1:8090#' /etc/crowdsec/local_api_credentials.yaml

(0.0.0.0 only because the Caddy container has to reach it, see below. If you skip the Caddy bouncer, keep 127.0.0.1:8090.)

First start pulls nothing. The very first community-blocklist pull ran before the scenario list was registered ("scenario list is empty, will not pull yet", 0 entries). A systemctl restart crowdsec pulled 15,000 IPs a minute later. Check with cscli decisions list --origin CAPI | wc -l.

Log sources and collections

cscli collections install crowdsecurity/caddy
cp acquis.d/caddy.yaml /etc/crowdsec/acquis.d/

The installer already adds crowdsecurity/sshd and a journald source for ssh.service.

Firewall bouncer

apt install crowdsec-firewall-bouncer-iptables

The package registers itself with the Local API. Then apply firewall-bouncer.yaml (mode nftables) and restart it. Why not the default iptables mode: it only inserts into INPUT. SSH is blocked, but every Docker-published port (80, 443, 2222) stays reachable for a banned IP, the same hole as fail2ban in episode 10. Adding DOCKER-USER fixes that until the next ufw reload, which flushes the chain and nobody puts the rule back.

Caddy bouncer and AppSec (WAF)

  1. Build the Caddy image with the modules (caddy/Dockerfile), elsewhere, and load it on the box.

  2. AppSec collections, plus the rules the default config references but the collections do not install (without them crowdsec does not start: "no appsec-rules found for pattern crowdsecurity/experimental-*", then the same for generic-*):

    cscli collections install crowdsecurity/appsec-virtual-patching crowdsecurity/appsec-generic-rules
    cscli appsec-rules install crowdsecurity/experimental-no-user-agent \
      crowdsecurity/generic-freemarker-ssti crowdsecurity/generic-wordpress-uploads-listing \
      crowdsecurity/generic-wordpress-uploads-php
    cp acquis.d/appsec.yaml /etc/crowdsec/acquis.d/
    crowdsec -t && systemctl restart crowdsec
    

    Run crowdsec -t first. A failed restart means no Local API; the firewall keeps its current sets, but no new decisions arrive.

  3. Let the Docker network in, and only that network:

    ufw allow proto tcp from 172.18.0.0/16 to 172.17.0.1 port 8090,7422
    

    The container reaches the host as host.docker.internal (extra_hosts: host-gateway in compose.yml, which is 172.17.0.1). From the internet both ports stay filtered. The mesh interface wt0 is allowed wholesale by episode 5, so mesh peers can reach both; each still needs an API key.

  4. cscli bouncers add caddy-bouncer -o raw and put the key into .env as CROWDSEC_BOUNCER_KEY, then docker compose up -d caddy.

The vpn. site does not import the crowdsec snippet: it carries the NetBird gRPC streams, and a WAF round-trip on every stream message is not what you want between your peers.

Replaying old logs (and the forensic-mode trap)

crowdsec -dsn file:///path/to/log -type syslog is documented as replay mode. With CrowdSec 1.8.1 it shut down one second after reading the file and dropped every event still in the pipeline: 47 or 49 alerts on two identical runs, none after 02:07 of a 24 h log. Feeding the same file through a FIFO that stays open 60 s after the last line gives the full, repeatable result (133 alerts, twice):

mkfifo /tmp/replay.fifo
(cat sshd.log; sleep 60) > /tmp/replay.fifo &
crowdsec -c replay.yaml -order-event -dsn file:///tmp/replay.fifo -type syslog

Use a separate config (replay.yaml) with its own database, its own listen_uri and no online_client, or the replayed alerts land in your live database and go out as signals.

What goes out to CrowdSec

With the default config, every alert from an unmodified hub scenario is pushed as a signal: scenario name and hash, timestamp, your machine id, and the attacking IP. Custom or modified ("tainted") scenarios and manual decisions are not pushed unless you enroll in the console. Your own penetration tests are real alerts too: switch sharing: false under api.server.online_client while you attack your own box, or you report your test machine.