Confirmed live: unquoted integer literals in the ASN exclusion filter (evt.Enriched.ASNNumber in [21928, 14593]) made CrowdSec fatal-crash-loop at startup with "cannot use string as type int in array" — ASNNumber is a string field internally despite printing as a bare number in cscli output, same as IsoCode in the geo-allowlist scenario. Quote each ASN as a string to match, exactly like the working geo-allowlist pattern.
581 lines
30 KiB
Bash
581 lines
30 KiB
Bash
#!/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.
|
||
sudo cscli scenarios remove crowdsecurity/asterisk_bf crowdsecurity/asterisk_user_enum 2>/dev/null || true
|
||
echo " ✓ Wrote ASN-exempt local forks; disabled the hub originals"
|
||
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 <IP> # unban an IP
|
||
sudo cscli decisions add --ip <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.<timestamp>`
|
||
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 <url> --machine <name>`
|
||
registers and disables the local API server.
|
||
- On the CENTRAL machine: `sudo cscli machines validate <name>` 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
|