#!/bin/bash # services/crowdsec.sh — CrowdSec intrusion prevention (fail2ban successor). # Part of the modular post-install system (sourced by setup.sh). # # Can also be run standalone on any machine: # sudo bash crowdsec.sh # (Docker must already be installed when run standalone) # # CrowdSec is a SYSTEM install (apt repo + agent), NOT a docker-compose service: # • Installs the CrowdSec agent and the iptables firewall bouncer (enforces bans). # • Installs detection collections for SSH, Linux, Caddy and base HTTP scenarios. # • Reads Caddy's JSON access logs (/var/log/caddy/*.log) to spot attacks. # • Optionally pushes ban alerts to an ntfy topic. # • Adds community IP reputation + optional geo-enrichment on top. # # There is no ~/docker/crowdsec compose; we only create a docs-only folder there # with a README pointing at the real config under /etc/crowdsec. # ── Standalone bootstrap ────────────────────────────────────────────────────── # Detected when the script is executed directly rather than sourced by setup.sh. # Sets up helpers and globals, then defers execution until after the function # definition at the bottom of this file. if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then [[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; } _SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" _COMMON="$_SELF_DIR/../lib/common.sh" if [[ -f "$_COMMON" ]]; then # Full repo present — use the real helpers (picks up ~/docker/.config too) # shellcheck source=../lib/common.sh source "$_COMMON" else # One-off copy — inline minimal stubs so the script works without the repo log_info() { echo -e "\033[0;34m[INFO]\033[0m $*"; } log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; } log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; } log_error() { echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; } ensure_docker_dir_ownership() { chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } # Match common.sh's eval-based pattern so local vars in install_* are set correctly prompt_text() { local _q="$1" _def="$2" _var="$3" _r [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } read -r -p " $_q " _r eval "$_var='${_r:-$_def}'" } prompt_yn() { local _q="$1" _def="$2" _var="$3" _r [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } read -r -p " $_q " _r eval "$_var='${_r:-$_def}'" } write_readme() { local _dir="$1"; shift mkdir -p "$_dir" cat > "$_dir/README.md" } fi # Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR # ($HOME under sudo is /root, not the real user's home) ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}" ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "${HOME:-/root}")" DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}" DRY_RUN="${DRY_RUN:-false}" UNATTENDED="${UNATTENDED:-false}" SITE_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" SITE_DOMAIN="${SITE_DOMAIN:-example.com}" SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" CADDY_REMOTE_HOST="${CADDY_REMOTE_HOST:-}" register_service() { :; } # no-op — no wizard to register into _RUN_STANDALONE=1 fi # ───────────────────────────────────────────────────────────────────────────── register_service crowdsec homelab "Intrusion prevention: bans + geo + IP reputation (CrowdSec)" install_crowdsec() { log_info "Installing CrowdSec intrusion prevention..." local DOCS_DIR="$DOCKER_DIR/crowdsec" echo "" echo "┌─────────────────────────────────────────────────────────────────┐" echo "│ CROWDSEC - Intrusion Prevention (fail2ban successor) │" echo "│ Bans malicious IPs + geo-blocking + community IP reputation │" echo "│ Protects SSH, Caddy, and other services │" echo "└─────────────────────────────────────────────────────────────────┘" echo "" # ── DRY-RUN: describe the plan and bail before touching anything real ──── if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would install the CrowdSec agent (curl https://install.crowdsec.net | sh; apt install crowdsec)" echo "[DRY-RUN] Would install the firewall bouncer (crowdsec-firewall-bouncer-iptables)" echo "[DRY-RUN] Would ensure /var/log/caddy exists for log acquisition" echo "[DRY-RUN] Would install collections: sshd, linux, caddy, base-http-scenarios" echo "[DRY-RUN] Would write Caddy acquisition /etc/crowdsec/acquis.d/caddy.yaml" echo "[DRY-RUN] Would install crowdsecurity/asterisk + write an acquisition if asterisk-digital-ocean is installed" echo "[DRY-RUN] Would optionally wire ntfy ban alerts into the default profile" echo "[DRY-RUN] Would optionally register with a remote/central LAPI and disable the local one" echo "[DRY-RUN] Would enable + restart crowdsec and crowdsec-firewall-bouncer" echo "[DRY-RUN] Would write $DOCS_DIR/README.md (docs-only folder)" return 0 fi # ── 1. Install the CrowdSec agent ──────────────────────────────────────── if command -v cscli &> /dev/null; then echo " ✓ CrowdSec is already installed" else echo " Adding CrowdSec repository and installing agent..." if curl -s https://install.crowdsec.net | sudo sh && sudo apt install -y crowdsec; then echo " ✓ CrowdSec installed successfully" else echo " ⚠ Failed to install CrowdSec" echo " See https://docs.crowdsec.net/ for manual installation" fi fi # ── 2. Firewall bouncer (enforces bans via iptables/nftables) ──────────── echo " Installing firewall bouncer..." sudo apt install -y crowdsec-firewall-bouncer-iptables 2>/dev/null || \ echo " ⚠ Could not install firewall bouncer automatically" # ── 3. Create log directory for Caddy ──────────────────────────────────── if [ ! -d "/var/log/caddy" ]; then sudo mkdir -p /var/log/caddy sudo chmod 755 /var/log/caddy echo " ✓ Created /var/log/caddy directory" fi # ── 4. Detection collections: SSH, Caddy HTTP scenarios, base http ─────── echo " Installing CrowdSec collections (sshd, caddy, base-http)..." sudo cscli collections install crowdsecurity/sshd crowdsecurity/linux crowdsecurity/caddy crowdsecurity/base-http-scenarios 2>/dev/null || \ echo " ⚠ Some collections may already be installed" # ── 5. Tell CrowdSec to read Caddy's JSON access logs ──────────────────── local ACQUIS_FILE="/etc/crowdsec/acquis.d/caddy.yaml" if [ ! -f "$ACQUIS_FILE" ]; then echo " Creating Caddy log acquisition for CrowdSec..." sudo mkdir -p /etc/crowdsec/acquis.d local ACQUIS_CONTENT='filenames: - /var/log/caddy/*.log - /var/log/caddy/*-access.log labels: type: caddy' if echo "$ACQUIS_CONTENT" | sudo tee "$ACQUIS_FILE" > /dev/null; then echo " ✓ Created Caddy acquisition ($ACQUIS_FILE)" else echo " ⚠ Failed to create acquisition - create it manually" fi else echo " ✓ Caddy acquisition already exists" fi # ── 5b. SIP brute-force/enumeration protection, if asterisk-digital-ocean # is installed (services/asterisk-digital-ocean.sh patches Asterisk to log # security events — auth failures, registration scanning — to # $EA_DIR/logs/full. The plain LAN asterisk.sh doesn't emit that file yet, # so it's intentionally not detected here.) local ASTERISK_LOG_DIR="$DOCKER_DIR/asterisk-digital-ocean/logs" if [ -d "$ASTERISK_LOG_DIR" ]; then echo " Detected asterisk-digital-ocean — installing SIP brute-force/enumeration protection..." sudo cscli collections install crowdsecurity/asterisk 2>/dev/null || \ echo " ⚠ crowdsecurity/asterisk collection may already be installed" local ASTERISK_ACQUIS="/etc/crowdsec/acquis.d/asterisk-digital-ocean.yaml" if [ ! -f "$ASTERISK_ACQUIS" ]; then local ASTERISK_ACQUIS_CONTENT="filenames: - $ASTERISK_LOG_DIR/full - $ASTERISK_LOG_DIR/full.* labels: type: asterisk" if echo "$ASTERISK_ACQUIS_CONTENT" | sudo tee "$ASTERISK_ACQUIS" > /dev/null; then echo " ✓ Created Asterisk acquisition ($ASTERISK_ACQUIS)" else echo " ⚠ Failed to create Asterisk acquisition - create it manually" fi else echo " ✓ Asterisk acquisition already exists" fi # ── 5c. Optional: exempt specific carrier ASNs from Asterisk # brute-force bans only (SSH/web/geo protection stays normal). Confirmed # live: a phone on a CGNAT mobile/satellite carrier (Starlink, T-Mobile # home internet) shares one public IP across many customers and rotates # it — a device roaming WiFi<->mobile can get banned for someone else's # brute-force attempt on that same shared IP, or for its own # re-registration burst looking like one. This forks # crowdsecurity/asterisk_bf and crowdsecurity/asterisk_user_enum # locally with an ASN exclusion added to their filter, then disables # the hub originals so there's no double-processing of the same events. echo "" local ASN_EXEMPT="" prompt_yn "Exempt specific carrier ASNs from Asterisk brute-force bans only? (y/n):" "n" ASN_EXEMPT if [ "$ASN_EXEMPT" = "y" ] || [ "$ASN_EXEMPT" = "Y" ]; then sudo cscli collections install crowdsecurity/geoip-enrich 2>/dev/null || true echo " ASNs observed live: T-Mobile 21928, Starlink 14593. A carrier can operate" echo " more than one ASN — if the same carrier bans you again later under a" echo " different number, check the AS column in 'cscli decisions list' and add it." local ASN_LIST="" prompt_text " ASN numbers to exempt, space-separated:" "21928 14593" ASN_LIST if [ -n "$ASN_LIST" ]; then # ASNNumber is a string field internally (like IsoCode in the # geo-allowlist scenario above) even though it prints as a bare # number in cscli/explain output. Confirmed live: unquoted # integer literals here made CrowdSec fatal-crash-loop at # startup with "cannot use string as type int in array" — # every ASN must be quoted as a string to match. local _asn_expr _asn_expr="$(printf "'%s', " $ASN_LIST)" _asn_expr="${_asn_expr%, }" sudo mkdir -p /etc/crowdsec/scenarios sudo tee /etc/crowdsec/scenarios/local-asterisk_bf.yaml > /dev/null << ASTBF type: leaky name: local/asterisk_bf description: "Detect Asterisk user bruteforce (ASN-exempt fork of crowdsecurity/asterisk_bf)" filter: "evt.Meta.log_type == 'asterisk_failed_auth' && !(evt.Enriched.ASNNumber in [${_asn_expr}])" groupby: evt.Meta.source_ip leakspeed: 10s capacity: 5 blackhole: 1m labels: service: asterisk confidence: 3 spoofable: 0 classification: - attack.T1110 behavior: "sip:bruteforce" label: "Asterisk Bruteforce" remediation: true ASTBF sudo tee /etc/crowdsec/scenarios/local-asterisk_user_enum.yaml > /dev/null << ASTENUM type: leaky name: local/asterisk_user_enum description: "Detect Asterisk user enumeration bruteforce (ASN-exempt fork of crowdsecurity/asterisk_user_enum)" filter: "evt.Meta.log_type == 'asterisk_failed_auth' && !(evt.Enriched.ASNNumber in [${_asn_expr}])" groupby: evt.Meta.source_ip distinct: evt.Meta.target_user leakspeed: 10s capacity: 5 blackhole: 1m labels: service: asterisk confidence: 3 spoofable: 0 classification: - attack.T1087 - attack.T1589.001 - attack.T1110 behavior: "sip:bruteforce" label: "Asterisk User Enumeration" remediation: true ASTENUM # Disable the hub originals so they don't double-process the # same events alongside the ASN-exempt forks written above. # --force is required: these scenarios came in as part of the # crowdsecurity/asterisk collection, and cscli refuses to # remove/disable a collection member without it. Confirmed # live: without --force this failed silently (stderr # suppressed, "|| true" swallowed the non-zero exit), leaving # the un-exempted hub original running side-by-side with the # ASN-exempt fork the entire time — the fork's exemption # never actually took effect for anyone, since the original # scenario kept independently banning the same traffic with # no ASN awareness at all. if sudo cscli scenarios remove crowdsecurity/asterisk_bf crowdsecurity/asterisk_user_enum --force 2>/dev/null; then echo " ✓ Wrote ASN-exempt local forks; disabled the hub originals" else log_warning "Failed to disable the hub-original asterisk_bf/asterisk_user_enum scenarios —" log_warning "the ASN exemption below will NOT take effect until this is resolved. Run:" log_warning " sudo cscli scenarios remove crowdsecurity/asterisk_bf crowdsecurity/asterisk_user_enum --force" log_warning " sudo systemctl restart crowdsec" fi echo " ℹ Exempted ASNs: $ASN_LIST — SSH/web/geo-allowlist scenarios are unaffected" echo " ℹ Edit /etc/crowdsec/scenarios/local-asterisk_*.yaml to add/remove ASNs later" echo " (then: sudo systemctl restart crowdsec)" fi fi fi # ── 6. Geo-blocking + reputation (the capability fail2ban/Authelia lack) ─ echo "" echo " Geo-blocking & IP reputation (optional):" echo " Subscribe to community/3rd-party blocklists at:" echo " https://app.crowdsec.net/" echo "" local GEO_ALLOWLIST="" prompt_yn "Restrict Caddy-fronted web traffic to North America + Europe only (block every other country)? Does NOT affect SSH. (y/n):" "n" GEO_ALLOWLIST if [ "$GEO_ALLOWLIST" = "y" ] || [ "$GEO_ALLOWLIST" = "Y" ]; then echo " Installing geoip-enrich (tags every event with a country code; no MaxMind" echo " account needed — CrowdSec bundles its own redistributable GeoLite2 data)..." sudo cscli collections install crowdsecurity/geoip-enrich 2>/dev/null || \ echo " ⚠ crowdsecurity/geoip-enrich may already be installed" # North America + Europe, ISO 3166-1 alpha-2. Russia, Belarus, and # Turkey are left out (common abuse-traffic sources, and not really # "Europe" in the sense meant here). Core Eastern Europe (Bulgaria, # Czechia, Hungary, Moldova, Poland, Romania, Slovakia, Ukraine) is # left out too — the Balkans and Baltics (Albania, Bosnia, Croatia, # Estonia, Latvia, Lithuania, Montenegro, North Macedonia, Serbia, # Slovenia) are kept in deliberately, since several of them (Estonia # especially) don't fit the same risk profile despite the old # Cold-War grouping. To adjust, edit the two lists below and re-run, # or hand-edit $GEO_SCENARIO directly (it's plain YAML — no reinstall # needed, just restart crowdsec after). local _GEO_NA="US CA MX" local _GEO_EU="AD AL AT BA BE CH CY DE DK EE ES FI FR GB GR HR IE IS IT LI LT LU LV MC ME MK MT NL NO PT RS SE SI SM VA" local _geo_expr_list _geo_expr_list="$(printf "'%s', " $_GEO_NA $_GEO_EU)" _geo_expr_list="${_geo_expr_list%, }" # Scope: evt.Line.Labels.type is the acquisition-level "labels: type:" # field set in acquis.d/caddy.yaml (section 5 above) — this only ever # matches Caddy-sourced events, never SSH/syslog, so a mistake here # can't lock out the session running this installer. sudo mkdir -p /etc/crowdsec/scenarios local GEO_SCENARIO="/etc/crowdsec/scenarios/geo-allowlist-web.yaml" local GEO_SCENARIO_CONTENT="type: trigger name: local/geo-allowlist-web description: \"Block Caddy-fronted web requests from outside the allowed country list\" filter: \"evt.Line.Labels.type == 'caddy' && evt.Enriched.IsoCode != '' && !(evt.Enriched.IsoCode in [${_geo_expr_list}])\" groupby: evt.Meta.source_ip blackhole: 1m labels: service: http type: geo_allowlist remediation: true" if echo "$GEO_SCENARIO_CONTENT" | sudo tee "$GEO_SCENARIO" > /dev/null; then echo " ✓ Geo-allowlist scenario written ($GEO_SCENARIO)" echo " ⚠ This blocks ALL web visitors outside the allowed countries, including" echo " Let's Encrypt's out-of-region validation checks (deliberately used to" echo " prevent BGP-hijacking attacks) — if a cert renewal ever fails" echo " mysteriously, check this scenario first." echo " ℹ Allowed: $_GEO_NA $_GEO_EU" echo " ℹ After restarting, verify it actually loaded (no typo/syntax issue):" echo " sudo cscli metrics | grep geo-allowlist" echo " sudo systemctl status crowdsec # should stay 'active', not restart-looping" else echo " ⚠ Failed to write geo-allowlist scenario — create it manually" fi fi # ── 7. Optional: push ban alerts to ntfy ───────────────────────────────── echo "" local CS_NTFY="" prompt_yn "Send CrowdSec ban alerts to an ntfy topic? (y/n):" "n" CS_NTFY if [ "$CS_NTFY" = "y" ] || [ "$CS_NTFY" = "Y" ]; then # Prefer a locally-installed ntfy's own base-url as the default, if one # exists and actually looks configured (not still the placeholder # domain ntfy.sh writes when no SITE_DOMAIN was set at its own install # time). Otherwise, nudge toward a hosted instance elsewhere (e.g. a # homelab) instead of silently defaulting to the public ntfy.sh. local _ntfy_default="https://ntfy.sh/crowdsec-alerts" if [ -f "$DOCKER_DIR/ntfy/config/server.yml" ]; then local _local_base_url _local_base_url="$(grep -oP '(?<=base-url: ")[^"]+' "$DOCKER_DIR/ntfy/config/server.yml" 2>/dev/null || true)" if [ -n "$_local_base_url" ] && [ "$_local_base_url" != "https://ntfy.example.com" ]; then _ntfy_default="${_local_base_url}/crowdsec-alerts" echo " Detected a configured local ntfy instance at $_local_base_url — using it as the default." fi fi if [ "$_ntfy_default" = "https://ntfy.sh/crowdsec-alerts" ]; then echo " No configured ntfy instance detected on this box. If you have one hosted" echo " elsewhere (e.g. a homelab), enter its topic URL below instead of the public" echo " ntfy.sh default — e.g. https://ntfy.your-homelab.com/crowdsec-alerts" fi local CS_NTFY_URL="" prompt_text " ntfy topic URL:" "$_ntfy_default" CS_NTFY_URL sudo mkdir -p /etc/crowdsec/notifications local NTFY_FILE="/etc/crowdsec/notifications/ntfy.yaml" local NTFY_CONTENT="type: http name: ntfy log_level: info format: | {{range . -}} {{range .Decisions -}} {{.Value}} banned: {{.Scenario}} for {{.Duration}} Check all bans: sudo cscli decisions list Unban this IP: sudo cscli decisions delete --ip {{.Value}} {{end -}} {{end -}} url: $CS_NTFY_URL method: POST headers: Title: CrowdSec ban Priority: high Tags: rotating_light" if echo "$NTFY_CONTENT" | sudo tee "$NTFY_FILE" > /dev/null; then echo " ✓ Created ntfy notification ($NTFY_FILE)" # Wire the notification into the default profile (only once) if ! grep -qE "^\s*- ntfy" /etc/crowdsec/profiles.yaml 2>/dev/null; then sudo awk '1; /^on_success:/ && !d {print "notifications:"; print " - ntfy"; d=1}' \ /etc/crowdsec/profiles.yaml | sudo tee /etc/crowdsec/profiles.yaml.new > /dev/null \ && sudo mv /etc/crowdsec/profiles.yaml.new /etc/crowdsec/profiles.yaml echo " ✓ Enabled ntfy alerts in CrowdSec default profile" else echo " ✓ ntfy already referenced in CrowdSec profile" fi echo " ℹ Alerts fire when an IP is banned (after repeated failed attempts)," echo " not on every individual failed login." else echo " ⚠ Failed to write ntfy notification config" fi fi # ── 7b. Optional: point this agent at a remote/central LAPI ────────────── # CrowdSec's real multi-server support: parsers/scenarios/bouncer still # run locally (banning only works where traffic actually arrives), but # the decision database (LAPI) can live on one central machine instead # of every box running its own. Useful if you already have CrowdSec on # a homelab and don't want a second LAPI+SQLite DB on this droplet. echo "" local USE_REMOTE_LAPI="" _REMOTE_LAPI_PENDING="" prompt_yn "Point this agent at a remote/central LAPI instead of running its own (e.g. one already on a homelab)? (y/n):" "n" USE_REMOTE_LAPI if [ "$USE_REMOTE_LAPI" = "y" ] || [ "$USE_REMOTE_LAPI" = "Y" ]; then echo "" echo " This registers this machine and disables its local API server." echo " The registration is PENDING until approved on the central LAPI" echo " machine — that approval step can't be automated from here." echo "" local LAPI_URL="" LAPI_MACHINE="" prompt_text " Central LAPI URL (e.g. http://homelab-ip:8080):" "" LAPI_URL prompt_text " Machine name to register as:" "$(hostname)" LAPI_MACHINE if [ -n "$LAPI_URL" ]; then if sudo cscli lapi register -u "$LAPI_URL" --machine "$LAPI_MACHINE"; then echo " ✓ Registered with $LAPI_URL as '$LAPI_MACHINE'" # Disable the local API server (remove the 'api.server:' block # from config.yaml) now that this agent forwards to the # central one instead. Backed up first — this is a direct # edit to CrowdSec's core config. local CS_CONFIG="/etc/crowdsec/config.yaml" local CS_BACKUP="$CS_CONFIG.backup.$(date +%Y%m%d-%H%M%S)" sudo cp "$CS_CONFIG" "$CS_BACKUP" sudo awk ' /^ server:/ { skip=1; next } skip && /^([a-zA-Z]| [a-zA-Z])/ { skip=0 } !skip { print } ' "$CS_CONFIG" | sudo tee "$CS_CONFIG.new" > /dev/null \ && sudo mv "$CS_CONFIG.new" "$CS_CONFIG" echo " ✓ Local API server disabled in config.yaml (backup: $(basename "$CS_BACKUP"))" echo "" echo " ⚠ Not usable yet — on the CENTRAL LAPI machine, run:" echo " sudo cscli machines validate $LAPI_MACHINE" echo " Then restart this agent: sudo systemctl restart crowdsec" echo " If it fails to start afterward, restore the backup and check logs:" echo " sudo cp $CS_BACKUP $CS_CONFIG && sudo systemctl restart crowdsec" _REMOTE_LAPI_PENDING="y" else echo " ⚠ cscli lapi register failed — keeping the local LAPI. See:" echo " sudo cscli lapi register -u $LAPI_URL --machine $LAPI_MACHINE" fi else echo " No URL entered — keeping the local LAPI." fi fi # ── 8. Restart services to apply ───────────────────────────────────────── if [ "$_REMOTE_LAPI_PENDING" = "y" ]; then echo "" echo " Skipping the restart below — it would fail until the machine is" echo " validated on the central LAPI (see above). Restart manually after:" echo " sudo systemctl restart crowdsec" else local RESTART_CS="" prompt_yn "Restart CrowdSec to apply changes? (y/n):" "y" RESTART_CS if [ "$RESTART_CS" = "y" ] || [ "$RESTART_CS" = "Y" ]; then sudo systemctl enable crowdsec 2>/dev/null || true if sudo systemctl restart crowdsec; then echo " ✓ CrowdSec restarted successfully" sudo systemctl enable crowdsec-firewall-bouncer 2>/dev/null || true sudo systemctl restart crowdsec-firewall-bouncer 2>/dev/null || true sleep 2 sudo cscli metrics 2>/dev/null | head -20 || true else echo " ⚠ Failed to restart CrowdSec" echo " Check logs: sudo journalctl -u crowdsec -n 50" fi fi fi # ── 9. Docs-only folder under ~/docker for discoverability ─────────────── write_readme "$DOCS_DIR" << 'CROWDSEC_README' # CrowdSec — intrusion prevention CrowdSec is a **system service** (installed via apt), not a Docker container, so there is no `docker-compose.yml` in this folder — it exists only to document the install. The real configuration lives under `/etc/crowdsec`. ## What it does - Detects malicious behaviour (SSH brute force, web scans, SIP brute force/enumeration if `asterisk-digital-ocean` is installed) by parsing logs. - Bans offending IPs via the **firewall bouncer** (iptables/nftables). - Pulls **community IP reputation** blocklists so known-bad IPs are blocked before they ever touch your services. - Optionally enriches events with **geo/ASN** data for geo-blocking. ## Key commands ``` sudo cscli metrics # parsers/scenarios/acquisition health sudo cscli decisions list # currently banned IPs sudo cscli decisions delete --ip # unban an IP sudo cscli decisions add --ip # manually ban an IP sudo cscli alerts list # recent alerts sudo cscli collections list # installed detection collections ``` ## Where configs live - Log acquisition (what to watch): `/etc/crowdsec/acquis.d/` - Caddy access logs: `/etc/crowdsec/acquis.d/caddy.yaml` (`/var/log/caddy/*.log` — Caddy writes JSON access logs there) - Asterisk SIP auth events (if `asterisk-digital-ocean` is installed): `/etc/crowdsec/acquis.d/asterisk-digital-ocean.yaml` (`~/docker/asterisk-digital-ocean/logs/full` — auth failures, registration scans) - Notifications: `/etc/crowdsec/notifications/` - ntfy ban alerts (if enabled): `/etc/crowdsec/notifications/ntfy.yaml`, wired into `/etc/crowdsec/profiles.yaml` - Bouncer config: `/etc/crowdsec/bouncers/` - Remote/central LAPI (if enabled): `/etc/crowdsec/local_api_credentials.yaml` points at the remote URL; the local API server block is removed from `/etc/crowdsec/config.yaml` (backed up as `config.yaml.backup.` next to it before editing). Parsers, scenarios, and the firewall bouncer still run locally regardless — only the decision database is centralized. ## Multi-server (remote LAPI) notes - On THIS machine: `sudo cscli lapi register -u --machine ` registers and disables the local API server. - On the CENTRAL machine: `sudo cscli machines validate ` approves it — not automated, since that's a different box. - Check registration status here: `sudo cscli lapi status` - Revert: restore the `config.yaml` backup and `sudo systemctl restart crowdsec`. ## Geo + reputation notes - Geo-enrichment (country/ASN tagging) is optional: `sudo cscli collections install crowdsecurity/geoip-enrich` - Subscribe to community / 3rd-party blocklists at https://app.crowdsec.net/ - ntfy alerts fire when an IP is **banned** (after repeated failed attempts), not on every individual failed login. - Geo-allowlist (if enabled): `/etc/crowdsec/scenarios/geo-allowlist-web.yaml` bans any Caddy-fronted web request from outside the countries listed in its `filter:` line. Web traffic only — SSH is never affected. Edit the country list directly in that file, then `sudo systemctl restart crowdsec`. This can block Let's Encrypt's out-of-region ACME validation checks; if a cert renewal fails mysteriously, check here first. - ASN-exempt Asterisk brute-force scenarios (if enabled, asterisk-digital-ocean only): `/etc/crowdsec/scenarios/local-asterisk_bf.yaml` and `local-asterisk_user_enum.yaml` — local forks of the stock hub scenarios with specific carrier ASNs excluded from their filter (the hub originals get disabled so events aren't double-processed). Exists because CGNAT mobile/ satellite carriers (Starlink, T-Mobile home internet) share one public IP across many customers, so a phone roaming WiFi↔mobile can get banned for someone else's brute-force attempt on that same shared IP. SSH, web, and the geo-allowlist are all unaffected — this only loosens Asterisk auth-failure detection for the listed ASNs. Edit the `filter:` line in both files to add/remove ASNs, then `sudo systemctl restart crowdsec`. Check the AS column in `cscli decisions list` if the same carrier bans you again under a different ASN later. ## Service control ``` sudo systemctl status crowdsec sudo systemctl restart crowdsec sudo systemctl status crowdsec-firewall-bouncer sudo journalctl -u crowdsec -n 50 ``` CROWDSEC_README echo "" echo " Useful commands:" echo " List active bans: sudo cscli decisions list" echo " List alerts: sudo cscli alerts list" echo " Manually ban IP: sudo cscli decisions add --ip 1.2.3.4" echo " Unban IP: sudo cscli decisions delete --ip 1.2.3.4" echo " Show metrics: sudo cscli metrics" echo "" } # Run immediately when executed directly (deferred until after function definition) [[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_crowdsec