Files
Claude 8b843ca1c1 Fold asterisk-digital-ocean into asterisk with droplet auto-detection
services/asterisk-digital-ocean.sh was a near-verbatim copy of
services/asterisk.sh — same vendor refresh, compose template, messaging
dialplan, presence alerts, UFW rules and dashboard/trunk chaining, with the
helper functions renamed _asterisk_do_*. Two copies meant every fix had to
land twice, and several never did.

There is now one `asterisk` service. It reads the DigitalOcean metadata
service and asks either way (so a droplet with metadata blocked, or another
provider's public VM, can still opt in), then gates the genuinely
droplet-specific behaviour on that one answer: swapfile for low-RAM plans,
public-FQDN-only setup with no LAN/VLAN prompts, a Caddy site block pinned
to that FQDN, the remote-Authelia option, and the doctl Cloud Firewall.

Two things that were droplet-only for no real reason now apply everywhere:
the entrypoint patch that writes security-level events to logs/full, and
the logrotate config for that file. Without them the Security Dashboard's
Security Log tab and CrowdSec's Asterisk acquisition were silently empty on
every home/LAN install; crowdsec.sh now detects either install directory.

Existing droplets are left alone: an install at ~/docker/asterisk-digital-ocean
keeps its directory and easy-asterisk-do container names, since its Caddyfile
block, UFW rules, Cloud Firewall, CrowdSec acquisition and PSTN trunk all
name those exactly. New installs use ~/docker/asterisk / easy-asterisk.
`sudo ./setup.sh asterisk-digital-ocean` still works via a new SERVICE_ALIAS
map in setup.sh, without a second menu entry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAddJGE1G6eGaPzmScG5Vh
2026-07-25 01:06:38 +00:00

660 lines
34 KiB
Bash
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 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
# ── Existing install? Offer update-in-place instead of a full reconfigure ─
# Everything below (ASN exemptions, geo-allowlist, ntfy, remote LAPI) is
# additive/idempotent at the file level, but there was no gate at all
# here before — every rerun re-asked all four optional questions
# unconditionally, which reads as "reconfigure" rather than "update" even
# though nothing already-configured was actually being destroyed.
local _REMOTE_LAPI_PENDING=""
local ASK_OPTIONAL=true
if command -v cscli &> /dev/null; then
echo " CrowdSec is already installed."
local CS_MODE=""
prompt_reinstall_mode CS_MODE 2>/dev/null || {
# prompt_reinstall_mode isn't defined in the standalone stub — fall
# back to a plain yes/no when run outside the full repo.
local _r=""
prompt_yn " Reconfigure the optional settings below (ASN exemptions, geo-allowlist, ntfy, remote LAPI)? (y/n):" "n" _r
[ "$_r" = "y" ] || [ "$_r" = "Y" ] && CS_MODE="fresh" || CS_MODE="update"
}
case "$CS_MODE" in
update)
ASK_OPTIONAL=false
log_info "Refreshing agent/collections/acquisitions only — ASN exemptions,"
log_info "geo-allowlist, ntfy alerts, and remote-LAPI settings are left exactly as"
log_info "they are. Choose 'fresh' instead to revisit any of those."
;;
cancel)
log_info "Leaving the existing CrowdSec install as-is."
return 0
;;
fresh)
log_info "Proceeding with a full reconfigure — every optional prompt below runs again."
;;
esac
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 is installed.
# services/asterisk.sh patches Asterisk to log security events — auth
# failures, registration scanning — to $EA_DIR/logs/full, which is what
# the acquisition below tails. Both directories are probed: a box set up
# before the droplet edition was merged back into `asterisk` still runs
# out of ~/docker/asterisk-digital-ocean. The logging patch used to be
# droplet-only; it now applies to every install, so a home/LAN box gets
# SIP protection here too.
local ASTERISK_LOG_DIR=""
local _ea_candidate
for _ea_candidate in "$DOCKER_DIR/asterisk-digital-ocean" "$DOCKER_DIR/asterisk"; do
[ -d "$_ea_candidate/logs" ] && { ASTERISK_LOG_DIR="$_ea_candidate/logs"; break; }
done
if [ -n "$ASTERISK_LOG_DIR" ]; then
echo " Detected Asterisk at ${ASTERISK_LOG_DIR%/logs} — installing SIP brute-force/enumeration protection..."
sudo cscli collections install crowdsecurity/asterisk 2>/dev/null || \
echo " ⚠ crowdsecurity/asterisk collection may already be installed"
# Filename kept as-is so a droplet that already has this acquisition
# isn't given a second one pointing at the same log.
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=""
if [ "$ASK_OPTIONAL" = true ]; then
prompt_yn "Exempt specific carrier ASNs from Asterisk brute-force bans only? (y/n):" "n" ASN_EXEMPT
fi
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=""
if [ "$ASK_OPTIONAL" = true ]; then
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
fi
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=""
if [ "$ASK_OPTIONAL" = true ]; then
prompt_yn "Send CrowdSec ban alerts to an ntfy topic? (y/n):" "n" CS_NTFY
fi
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=""
if [ "$ASK_OPTIONAL" = true ]; then
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
fi
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` 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.
## Rerunning this script
Rerunning offers **update** (refreshes the agent/collections/acquisitions
only — ASN exemptions, geo-allowlist, ntfy, and remote-LAPI settings are left
exactly as they are) or **fresh** (revisit every optional prompt again, same
as a first install). Nothing here is destructive either way — "fresh" only
overwrites a setting if you actually answer its prompt differently.
## 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` is installed):
`/etc/crowdsec/acquis.d/asterisk-digital-ocean.yaml` (filename kept from
when the droplet edition was its own service, so existing droplets aren't
given a duplicate acquisition)
(`~/docker/asterisk/logs/full`, or `~/docker/asterisk-digital-ocean/logs/full`
on a pre-merge droplet — 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 installs
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