Files
linux-scripts/nginx-security.sh
T
chiefgeek a1a17e81a1 Sync all scripts from website downloads — 352 scripts total
Includes updated JS challenge scripts with Claude-User whitelist,
same-site referer bypass, Blackbox-Exporter allowed bot, and all
new exporters, cheat sheets, and automation scripts.
2026-05-25 03:31:08 +02:00

1803 lines
61 KiB
Bash
Executable File

#!/bin/bash
################################################################################
# Script Name: nginx-security.sh
# Version: 1.0
# Description: Unified nginx security toolkit for standard (non-panel) servers.
# Consolidates bot-blocking, JS cookie challenge, and HEAD request
# blocking into a single subcommand-based script with shared helpers.
# For HestiaCP / VestaCP / myVesta servers, use hestia-security.sh.
#
# Author: Phil Connor
# Contact: contact@mylinux.work
# Website: https://mylinux.work
# License: MIT
#
# Subcommands:
# bot-block — AI scraper and SEO bot blocking via nginx map
# js-challenge — JavaScript cookie challenge for headless bot detection
# block-head — Block HTTP HEAD request crawlers/scrapers
# crowdsec — CrowdSec engine + nginx lua bouncer + log acquisition
# status — Show status of all security features
#
# Usage:
# sudo ./nginx-security.sh bot-block [OPTIONS]
# sudo ./nginx-security.sh js-challenge [OPTIONS]
# sudo ./nginx-security.sh block-head [OPTIONS]
# sudo ./nginx-security.sh crowdsec [OPTIONS]
# sudo ./nginx-security.sh status
#
################################################################################
set -euo pipefail
# =============================================================================
# SHARED HELPERS
# =============================================================================
# --- Colors (TTY-aware) ---
if [[ -t 1 ]]; then
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'
else
RED="" GREEN="" YELLOW="" CYAN="" BOLD="" NC=""
fi
# --- Logging (prefixed with # for Prometheus comment compatibility) ---
info() { echo -e "# ${GREEN}[OK]${NC} $*"; }
warn() { echo -e "# ${YELLOW}[WARN]${NC} $*"; }
step() { echo -e "# ${CYAN}[STEP]${NC} $*"; }
err() { echo -e "# ${RED}[ERROR]${NC} $*" >&2; }
# --- Global variables ---
CONF_DIR="/etc/nginx/conf.d"
SITES_DIR="/etc/nginx/sites-enabled"
DRY_RUN=false
TIMESTAMP=$(date +%s)
# --- Root check ---
require_root() {
if [[ $EUID -ne 0 && "$DRY_RUN" != "true" ]]; then
err "Must run as root (or use --dry-run)"
exit 1
fi
}
# --- Require nginx ---
require_nginx() {
if ! command -v nginx &>/dev/null; then
err "nginx not found"
exit 1
fi
}
# --- Backup a file with timestamp suffix ---
backup_file() {
local file="$1"
if [[ -f "$file" ]]; then
cp "$file" "${file}.bak.${TIMESTAMP}"
fi
}
# --- Validate nginx config ---
nginx_validate() {
step "Testing nginx configuration"
if [[ "$DRY_RUN" == "true" ]]; then
echo " Would run: nginx -t"
return 0
fi
if nginx -t 2>&1; then
info "nginx config valid"
return 0
else
err "nginx config test failed"
return 1
fi
}
# --- Reload nginx ---
nginx_reload() {
step "Reloading nginx"
if [[ "$DRY_RUN" == "true" ]]; then
echo " Would run: systemctl reload nginx"
else
systemctl reload nginx
info "nginx reloaded"
fi
}
# --- Collect server block configs from sites-enabled and conf.d ---
# Args: single_conf skip_file [skip_file2 ...]
collect_configs() {
local single_conf="${1:-}"
shift || true
local skip_files=("$@")
local configs=()
if [[ -n "$single_conf" ]]; then
if [[ ! -f "$single_conf" ]]; then
err "Config file not found: ${single_conf}"
exit 1
fi
configs+=("$single_conf")
else
if [[ -d "$SITES_DIR" ]]; then
for f in "$SITES_DIR"/*; do
[[ -f "$f" ]] && configs+=("$f")
done
fi
if [[ -d "$CONF_DIR" ]]; then
for f in "$CONF_DIR"/*.conf; do
[[ -f "$f" ]] || continue
local skip=false
for sf in "${skip_files[@]}"; do
[[ "$f" == "$sf" ]] && skip=true && break
done
[[ "$skip" == "true" ]] && continue
configs+=("$f")
done
fi
fi
for f in "${configs[@]}"; do
grep -qP '^\s*server\s*\{' "$f" 2>/dev/null && echo "$f"
done
}
# =============================================================================
# MAIN USAGE
# =============================================================================
usage_main() {
cat <<EOF
Usage: sudo $(basename "$0") COMMAND [OPTIONS]
Unified nginx security toolkit for standard (non-panel) servers.
For HestiaCP / VestaCP / myVesta, use hestia-security.sh instead.
Commands:
bot-block AI scraper and SEO bot blocking via nginx map
js-challenge JavaScript cookie challenge for headless bot detection
block-head Block HTTP HEAD request crawlers/scrapers
crowdsec CrowdSec engine + nginx lua bouncer + log acquisition
status Show status of all security features
Run '$(basename "$0") COMMAND --help' for subcommand options.
EOF
exit 0
}
# =============================================================================
# SUBCOMMAND: bot-block
# =============================================================================
cmd_bot_block() {
local MAP_FILE="${CONF_DIR}/bot-block.conf"
local SINGLE_CONF=""
local STATUS_CODE="444"
local REMOVE=false
local MARKER_START="# bot-block-managed-start"
local MARKER_END="# bot-block-managed-end"
usage_bot_block() {
cat <<EOF
Usage: sudo $(basename "$0") bot-block [OPTIONS]
Blocks AI scrapers, SEO bots, vulnerability scanners, and scraping frameworks
on standard nginx servers by creating an http-level map and injecting
bot-blocking rules into server blocks.
Options:
--dry-run Show what would be done without making changes
--remove Remove bot-block.conf and strip injected rules
--conf FILE Only modify a specific config file
--status-code CODE HTTP status code to return (default: 444)
Common alternatives: 403, 444
-h, --help Show this help
Examples:
sudo $(basename "$0") bot-block
sudo $(basename "$0") bot-block --dry-run
sudo $(basename "$0") bot-block --conf /etc/nginx/sites-enabled/mysite.conf
sudo $(basename "$0") bot-block --status-code 403
sudo $(basename "$0") bot-block --remove
EOF
exit 0
}
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) DRY_RUN=true; shift ;;
--remove) REMOVE=true; shift ;;
--conf) SINGLE_CONF="$2"; shift 2 ;;
--status-code) STATUS_CODE="$2"; shift 2 ;;
-h|--help) usage_bot_block ;;
*) echo "Unknown option: $1"; usage_bot_block ;;
esac
done
require_root
require_nginx
# =====================================================
# REMOVE MODE
# =====================================================
if [[ "$REMOVE" == "true" ]]; then
step "Removing bot-block configuration"
if [[ -f "$MAP_FILE" ]]; then
if [[ "$DRY_RUN" == "true" ]]; then
echo " Would remove: ${MAP_FILE}"
else
rm -f "$MAP_FILE"
info "Removed: ${MAP_FILE}"
fi
else
warn "Map file not found: ${MAP_FILE} (already removed?)"
fi
step "Scanning for injected bot-block rules"
mapfile -t configs < <(collect_configs "$SINGLE_CONF" "$MAP_FILE")
if [[ ${#configs[@]} -eq 0 ]]; then
warn "No server block config files found"
else
for conf in "${configs[@]}"; do
if grep -q "$MARKER_START" "$conf" 2>/dev/null; then
if [[ "$DRY_RUN" == "true" ]]; then
echo " Would clean: ${conf}"
else
cp "$conf" "${conf}.bak.${TIMESTAMP}"
sed -i "/${MARKER_START}/,/${MARKER_END}/d" "$conf"
info "Cleaned: ${conf}"
fi
fi
done
fi
if nginx_validate; then
nginx_reload
else
warn "nginx config has errors — check other configs (js-challenge, etc.)"
fi
echo ""
echo -e "${BOLD}Bot-block rules removed.${NC}"
return 0
fi
# =====================================================
# INSTALL MODE
# =====================================================
# Step 1: Create nginx map
step "Creating bot-block map at ${MAP_FILE}"
local MAP_CONTENT='# Bot-blocking map for AI scrapers, SEO bots, and vulnerability scanners
# Generated by nginx-security.sh bot-block — https://mylinux.work
map $http_user_agent $is_bad_bot {
default 0;
# AI scrapers
~*ABEvalBot 1;
~*GPTBot 1;
~*ClaudeBot 1;
~*anthropic-ai 1;
~*CCBot 1;
~*Bytespider 1;
~*TikTokSpider 1;
~*cohere-ai 1;
~*PerplexityBot 1;
~*Diffbot 1;
~*MistralBot 1;
~*YandexGPTBot 1;
~*meta-externalagent 1;
~*Meta-ExternalFetcher 1;
~*meta-webindexer 1;
~*PetalBot 1;
~*Amazonbot 1;
~*Amzn-SearchBot 1;
~*AI2Bot 1;
~*Timpibot 1;
~*img2dataset 1;
~*YouBot 1;
~*HanaleiBot 1;
~*Trafilatura 1;
~*ShapBot 1;
# Defunct crawlers (spoofed user agents)
~*Exabot 1;
# Chinese search crawlers (no benefit for English sites)
~*Sogou 1;
# SEO scrapers
~*MJ12bot 1;
~*SemrushBot 1;
~*AhrefsBot 1;
~*DotBot 1;
~*DataForSeoBot 1;
~*SERanking 1;
# Vulnerability scanners
~*Nikto 1;
~*sqlmap 1;
~*Nmap 1;
~*masscan 1;
~*ZmEu 1;
~*Morpheus 1;
# Lead-gen / business intelligence bots
~*ospa-radar 1;
~*HubSeedsBot 1;
# AI scrapers / research bots
~*Aranet-SearchBot 1;
~*AzureAI-SearchBot 1;
~*MINERVA-DeepResearch 1;
~*NagetBot 1;
~*LAIABot 1;
~*pi-coding-agent 1;
# Probe / monitoring bots
~*CMS-Checker 1;
~*NexoFaviconBot 1;
~*AwarioBot 1;
~*AwarioSmartBot 1;
~*CopyousBot 1;
~*SurdotlyBot 1;
~*trendictionbot 1;
~*wpbot 1;
~*WebFetchTool 1;
~*YisouSpider 1;
# Scraping frameworks
~*Scrapy 1;
~*python-requests 1;
~*Go-http-client 1;
~*Java/ 1;
~*libwww-perl 1;
~*node-fetch 1;
~*HeadlessChrome 1;
# Outdated browsers (Chrome < 115 — almost certainly bots)
~*Chrome/([1-9][0-9]?|10[0-9]|11[0-4])\. 1;
# Empty / missing user agent
"" 1;
"-" 1;
}'
if [[ "$DRY_RUN" == "true" ]]; then
echo " Would create: ${MAP_FILE}"
else
if [[ -f "$MAP_FILE" ]]; then
cp "$MAP_FILE" "${MAP_FILE}.bak.${TIMESTAMP}"
warn "Existing map backed up"
fi
echo "$MAP_CONTENT" > "$MAP_FILE"
info "Map created: ${MAP_FILE}"
fi
# Step 2: Inject bot-blocking rule into server blocks
step "Scanning for server blocks to inject bot-blocking rule"
mapfile -t configs < <(collect_configs "$SINGLE_CONF" "$MAP_FILE" "${CONF_DIR}/js-challenge.conf")
if [[ ${#configs[@]} -eq 0 ]]; then
warn "No server block config files found in ${SITES_DIR} or ${CONF_DIR}"
else
local MODIFIED=0
for conf in "${configs[@]}"; do
if grep -q "$MARKER_START" "$conf" 2>/dev/null; then
warn "Already managed: ${conf} — skipping"
continue
fi
if [[ "$DRY_RUN" == "true" ]]; then
echo " Would inject into: ${conf}"
MODIFIED=$((MODIFIED + 1))
continue
fi
cp "$conf" "${conf}.bak.${TIMESTAMP}"
local BOT_BLOCK=""
BOT_BLOCK+=" ${MARKER_START}\n"
BOT_BLOCK+=" if (\$is_bad_bot) {\n"
BOT_BLOCK+=" return ${STATUS_CODE};\n"
BOT_BLOCK+=" }\n"
BOT_BLOCK+=" # Block broken srcset scrapers\n"
BOT_BLOCK+=" if (\$request_uri ~* \"%20[0-9]+w,https?://\") {\n"
BOT_BLOCK+=" return ${STATUS_CODE};\n"
BOT_BLOCK+=" }\n"
BOT_BLOCK+=" # Block spoofed referers with fragment identifiers (real browsers strip these)\n"
BOT_BLOCK+=" if (\$http_referer ~* \"#\") {\n"
BOT_BLOCK+=" return ${STATUS_CODE};\n"
BOT_BLOCK+=" }\n"
BOT_BLOCK+=" # Block non-GET/HEAD methods (static sites never need POST/PUT/DELETE)\n"
BOT_BLOCK+=" if (\$request_method !~ ^(GET|HEAD)\$ ) {\n"
BOT_BLOCK+=" return ${STATUS_CODE};\n"
BOT_BLOCK+=" }\n"
BOT_BLOCK+=" ${MARKER_END}"
awk -v block="$BOT_BLOCK" '
/^\s*server\s*\{/ { in_server = 1; injected = 0 }
in_server && !injected && /^\s*location\s/ {
printf "%s\n", block
print ""
injected = 1
}
/^\s*\}/ && in_server {
# Track brace depth to know when server block ends
}
{ print }
' "$conf" > "${conf}.tmp"
mv "${conf}.tmp" "$conf"
info "Injected into: ${conf}"
MODIFIED=$((MODIFIED + 1))
done
if [[ $MODIFIED -eq 0 ]]; then
warn "No files modified (all already managed)"
fi
fi
# Step 3: Validate and reload
nginx_validate || exit 1
nginx_reload
# Summary
echo ""
echo -e "${BOLD}Done.${NC}"
echo ""
echo " Map: ${MAP_FILE}"
echo " Status code: ${STATUS_CODE}"
if [[ -n "$SINGLE_CONF" ]]; then
echo " Config: ${SINGLE_CONF}"
else
echo " Scanned: ${SITES_DIR}/ and ${CONF_DIR}/*.conf"
fi
echo ""
echo " To remove: sudo $(basename "$0") bot-block --remove"
echo ""
echo " Verify: curl -A 'GPTBot' -o /dev/null -s -w '%{http_code}' https://yourdomain.com"
echo " Expected: 444 (connection dropped) or 000 (no response)"
}
# =============================================================================
# SUBCOMMAND: js-challenge
# =============================================================================
cmd_js_challenge() {
local CHALLENGE_MAP="${CONF_DIR}/js-challenge.conf"
local CHALLENGE_DIR="/var/www/js-challenge"
local CHALLENGE_HTML="${CHALLENGE_DIR}/challenge.html"
local STATE_FILE="/etc/nginx/js-challenge.env"
local CHALLENGE_PATH="/_bc"
local REMOVE=false
local COOKIE_MAX_AGE=86400
local TARPIT_COUNTRIES="${TARPIT_COUNTRIES:-CN}"
local TARPIT_RATE="${TARPIT_RATE:-50}"
local CHALLENGE_RATE="${CHALLENGE_RATE:-1}"
local CHALLENGE_BURST="${CHALLENGE_BURST:-3}"
local COOKIE_NAME=""
local COOKIE_VALUE=""
local USE_DBIP=false
usage_js_challenge() {
cat <<EOF
Usage: sudo $(basename "$0") js-challenge [OPTIONS]
Adds a JavaScript cookie challenge to nginx. Visitors that don't execute
JavaScript (headless scrapers, curl-based bots) are silently dropped.
Legitimate search engine crawlers are whitelisted by user agent.
This is designed to work alongside bot-block — run that first to block
known bad bots, then use this to catch the ones spoofing real browser
user agents.
The cookie name and token are randomized per installation. Re-running the script
rotates them, immediately invalidating any bot-cached bypass cookies.
Options:
--dry-run Show what would be done without making changes
--remove Remove challenge config, HTML, and state file
--db-ip Install geoip2 module + DB-IP free databases for tarpit
--cookie-ttl SECONDS Cookie lifetime in seconds (default: 86400 / 24h)
--tarpit-countries CC Space-separated GeoIP country codes to tarpit (default: CN)
--tarpit-rate BYTES Response rate in bytes/sec for tarpitted visitors (default: 50)
--challenge-rate N Sustained challenge requests per minute per IP (default: 1)
--challenge-burst N Initial burst of challenge requests allowed (default: 3)
-h, --help Show this help
Examples:
sudo $(basename "$0") js-challenge --db-ip
sudo $(basename "$0") js-challenge --dry-run
sudo $(basename "$0") js-challenge --remove
EOF
exit 0
}
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) DRY_RUN=true; shift ;;
--remove) REMOVE=true; shift ;;
--db-ip) USE_DBIP=true; shift ;;
--cookie-ttl) COOKIE_MAX_AGE="$2"; shift 2 ;;
--tarpit-countries) TARPIT_COUNTRIES="$2"; shift 2 ;;
--tarpit-rate) TARPIT_RATE="$2"; shift 2 ;;
--challenge-rate) CHALLENGE_RATE="$2"; shift 2 ;;
--challenge-burst) CHALLENGE_BURST="$2"; shift 2 ;;
-h|--help) usage_js_challenge ;;
*) echo "Unknown option: $1"; usage_js_challenge ;;
esac
done
require_root
# --- Credential generation ---
_jsc_generate() {
COOKIE_NAME="bc$(openssl rand -hex 1)"
COOKIE_VALUE="$(openssl rand -hex 16)"
}
_jsc_save() {
if [[ "$DRY_RUN" != "true" ]]; then
cat > "$STATE_FILE" <<ENVEOF
# JS challenge credentials — generated $(date -Iseconds)
# Re-run the script to rotate these values
COOKIE_NAME='${COOKIE_NAME}'
COOKIE_VALUE='${COOKIE_VALUE}'
CHALLENGE_PATH='${CHALLENGE_PATH}'
ENVEOF
chmod 600 "$STATE_FILE"
fi
}
if [[ "$REMOVE" != "true" ]]; then
_jsc_generate
info "Generated new credentials — cookie: ${COOKIE_NAME} token: ${COOKIE_VALUE:0:8}..."
fi
# =====================================================
# Remove mode
# =====================================================
if [[ "$REMOVE" == "true" ]]; then
step "Removing JS challenge configuration"
# Remove injected blocks from server configs FIRST (before deleting
# the map file that defines the variables they reference)
if [[ "$DRY_RUN" == "true" ]]; then
echo " Would clean JS challenge blocks from server configs"
else
mapfile -t configs < <(collect_configs "" "")
for conf in "${configs[@]}"; do
if grep -q 'js-challenge-managed-start' "$conf" 2>/dev/null; then
cp "$conf" "${conf}.bak.${TIMESTAMP}"
sed -i '/js-challenge-managed-start/,/js-challenge-managed-end/d' "$conf"
info "Removed JS challenge block from: ${conf}"
fi
done
fi
if [[ "$DRY_RUN" == "true" ]]; then
echo " Would remove: ${CHALLENGE_MAP}"
echo " Would remove: ${CHALLENGE_DIR}"
echo " Would remove: ${STATE_FILE}"
echo " Would run: nginx -t && systemctl reload nginx"
else
[[ -f "$CHALLENGE_MAP" ]] && rm -f "$CHALLENGE_MAP" && info "Removed: ${CHALLENGE_MAP}"
[[ -d "$CHALLENGE_DIR" ]] && rm -rf "$CHALLENGE_DIR" && info "Removed: ${CHALLENGE_DIR}"
[[ -f "$STATE_FILE" ]] && rm -f "$STATE_FILE" && info "Removed: ${STATE_FILE}"
fi
if nginx_validate; then
nginx_reload
else
warn "nginx config has errors — check other configs (bot-block, etc.)"
fi
echo ""
echo -e "${BOLD}JS challenge removed.${NC}"
return 0
fi
require_nginx
# =====================================================
# Step 1: Create the challenge HTML page
# =====================================================
step "Creating challenge page at ${CHALLENGE_HTML}"
local CHALLENGE_CONTENT='<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>Verifying</title></head>
<body>
<noscript><p>JavaScript is required to access this site.</p></noscript>
<p id="msg" style="display:none;font-family:sans-serif;text-align:center;margin-top:2em;">
Cookies must be enabled to access this site.</p>
<script>
(function(){
var p = new URLSearchParams(window.location.search);
var r = p.get("r") || "/";
if (r.charAt(0) !== "/") r = "/";
var cn = "'"${COOKIE_NAME}"'";
var cv = "'"${COOKIE_VALUE}"'";
var secure = (location.protocol === "https:") ? ";Secure" : "";
document.cookie = cn + "=" + cv + ";path=/;max-age='"${COOKIE_MAX_AGE}"';SameSite=Lax" + secure;
var origRef = p.get("ref") || "direct";
if (origRef !== "direct") {
try { var rh = new URL(origRef).hostname; if (rh === location.hostname) origRef = "direct"; } catch(e) {}
}
document.cookie = "_bc_ref=" + encodeURIComponent(origRef) + ";path=/;max-age=120;SameSite=Lax" + secure;
if (document.cookie.split(/;\s*/).every(function(c){ return c.indexOf(cn + "=") !== 0; })) {
document.getElementById("msg").style.display = "block";
return;
}
window.location.replace(r);
})();
</script>
</body>
</html>'
if [[ "$DRY_RUN" == "true" ]]; then
echo " Would create: ${CHALLENGE_DIR}/"
echo " Would create: ${CHALLENGE_HTML}"
else
mkdir -p "$CHALLENGE_DIR"
echo "$CHALLENGE_CONTENT" > "$CHALLENGE_HTML"
info "Challenge page created: ${CHALLENGE_HTML}"
fi
# Save credentials
_jsc_save
# =====================================================
# Step 2: Create nginx map config
# =====================================================
step "Creating JS challenge map at ${CHALLENGE_MAP}"
local NGINX_COOKIE_VAR="\$cookie_${COOKIE_NAME}"
# Install geoip2 module + DB-IP databases if requested
if [[ "$USE_DBIP" == "true" ]]; then
step "Installing geoip2 module and DB-IP databases"
if [[ "$DRY_RUN" == "true" ]]; then
echo " Would install geoip2 module and download DB-IP databases"
else
# Install geoip2 nginx module
if ! nginx -V 2>&1 | grep -q 'http_geoip2' && \
! ls /etc/nginx/modules-enabled/*geoip2* 2>/dev/null | grep -q . && \
! ls /usr/lib/nginx/modules/*geoip2* 2>/dev/null | grep -q . ; then
if command -v apt &>/dev/null; then
apt install -y libnginx-mod-http-geoip2 2>/dev/null || \
warn "Could not install libnginx-mod-http-geoip2 — try installing manually"
elif command -v dnf &>/dev/null; then
dnf install -y nginx-mod-http-geoip2 2>/dev/null || \
warn "Could not install nginx-mod-http-geoip2"
fi
else
info "geoip2 module already installed"
fi
# Download DB-IP free databases
local geoip_dir="/usr/share/GeoIP"
mkdir -p "$geoip_dir"
local dbip_month
dbip_month=$(date +%Y-%m)
local tmpdir="/tmp/dbip-download-$$"
mkdir -p "$tmpdir"
if [[ ! -f "${geoip_dir}/GeoLite2-City.mmdb" ]] || [[ "$USE_DBIP" == "true" ]]; then
step "Downloading DB-IP Country database"
if curl -fsSL "https://download.db-ip.com/free/dbip-country-lite-${dbip_month}.mmdb.gz" \
-o "${tmpdir}/country.mmdb.gz" 2>/dev/null; then
gunzip -f "${tmpdir}/country.mmdb.gz"
mv "${tmpdir}/country.mmdb" "${geoip_dir}/GeoLite2-City.mmdb"
info "DB-IP Country → ${geoip_dir}/GeoLite2-City.mmdb"
else
warn "Failed to download DB-IP Country database"
fi
step "Downloading DB-IP ASN database"
if curl -fsSL "https://download.db-ip.com/free/dbip-asn-lite-${dbip_month}.mmdb.gz" \
-o "${tmpdir}/asn.mmdb.gz" 2>/dev/null; then
gunzip -f "${tmpdir}/asn.mmdb.gz"
mv "${tmpdir}/asn.mmdb" "${geoip_dir}/GeoLite2-ASN.mmdb"
info "DB-IP ASN → ${geoip_dir}/GeoLite2-ASN.mmdb"
else
warn "Failed to download DB-IP ASN database"
fi
fi
rm -rf "$tmpdir"
# Create monthly cron job to update DB-IP databases
local cron_file="/etc/cron.monthly/dbip-update"
if [[ ! -f "$cron_file" ]]; then
step "Creating monthly DB-IP update cron job"
cat > "$cron_file" <<'CRONEOF'
#!/bin/sh
# Monthly DB-IP database update — generated by nginx-security.sh
MONTH=$(date +%Y-%m)
TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT
if curl -fsSL "https://download.db-ip.com/free/dbip-country-lite-${MONTH}.mmdb.gz" -o "${TMPDIR}/country.mmdb.gz" && \
curl -fsSL "https://download.db-ip.com/free/dbip-asn-lite-${MONTH}.mmdb.gz" -o "${TMPDIR}/asn.mmdb.gz" && \
gunzip "${TMPDIR}/country.mmdb.gz" && \
gunzip "${TMPDIR}/asn.mmdb.gz"; then
mv "${TMPDIR}/country.mmdb" "/usr/share/GeoIP/GeoLite2-City.mmdb"
mv "${TMPDIR}/asn.mmdb" "/usr/share/GeoIP/GeoLite2-ASN.mmdb"
logger -t dbip-update "DB-IP databases updated"
else
logger -t dbip-update -p user.err "DB-IP download failed — kept existing databases"
fi
CRONEOF
chmod 755 "$cron_file"
info "Created monthly update: ${cron_file}"
else
info "Monthly cron job already exists: ${cron_file}"
fi
fi
fi
# Check if geoip2 module is available and the database exists
local GEOIP2_BLOCK=""
local GEOIP2_AVAILABLE=false
local TARPIT_MAP_BLOCK=""
# Check if the module is loadable
if nginx -V 2>&1 | grep -q 'http_geoip2' || \
ls /etc/nginx/modules-enabled/*geoip2* 2>/dev/null | grep -q . || \
ls /usr/lib/nginx/modules/*geoip2* 2>/dev/null | grep -q . ; then
if [[ -f /usr/share/GeoIP/GeoLite2-City.mmdb ]] || \
[[ -f /usr/share/GeoIP/GeoLite2-Country.mmdb ]]; then
GEOIP2_AVAILABLE=true
else
warn "geoip2 module found but no GeoIP database in /usr/share/GeoIP/ — tarpit disabled"
fi
else
warn "geoip2 nginx module not installed — tarpit feature disabled"
warn "Install with: apt install libnginx-mod-http-geoip2 (Ubuntu/Debian)"
fi
if [[ "$GEOIP2_AVAILABLE" == "true" ]]; then
# Check if geoip2 country_code is already defined elsewhere
if ! grep -r 'geoip2_country_code' /etc/nginx/ \
--include='*.conf' --exclude='js-challenge.conf' --exclude='*.bak.*' \
-q 2>/dev/null; then
local mmdb="/usr/share/GeoIP/GeoLite2-City.mmdb"
[[ ! -f "$mmdb" ]] && mmdb="/usr/share/GeoIP/GeoLite2-Country.mmdb"
GEOIP2_BLOCK='
# ── GeoIP2: country lookup for tarpit decisions ──────────────────────
# Uses the City database (superset of Country). Adjust path if needed.
geoip2 '"${mmdb}"' {
$geoip2_country_code country iso_code;
}
'
step "No existing geoip2 country_code config found — adding to map config"
fi
# Build tarpit maps
local tarpit_entries=""
for cc in $TARPIT_COUNTRIES; do tarpit_entries="${tarpit_entries} \"${cc}\" 1;\n"; done
TARPIT_MAP_BLOCK="${GEOIP2_BLOCK}
# ── Tarpit: headless Chrome bots from suspect regions ─────────────────
# Visitors from tarpit countries with no external referrer (passed through
# the challenge redirect as the _bc_ref cookie) are served at a crawl.
# This drains headless Chrome resources (~200-500 MB RAM per instance)
# without giving the bot a clear \"blocked\" signal to adapt to.
#
# The _bc_ref cookie is set by the challenge page JS from the &ref= param.
# It contains the original HTTP Referer before the 302 redirect destroyed it.
# \"direct\" = no external referrer (typed URL or bot). Cookie expires in 120s.
# Check if visitor is from a tarpit country (requires geoip2 module)
map \$geoip2_country_code \$is_tarpit_country {
default 0;
$(for cc in $TARPIT_COUNTRIES; do echo " \"${cc}\" 1;"; done)
}
# Tarpit only if: tarpit country + no external referrer + passed JS challenge
map \"\$is_tarpit_country:\$cookie__bc_ref\" \$tarpit_client {
default 0;
\"1:direct\" 1;
\"1:\" 1;
}"
else
TARPIT_MAP_BLOCK='
# ── Tarpit: DISABLED (geoip2 module not available) ──────────────────
# Install libnginx-mod-http-geoip2 and re-run to enable tarpit.
# Without geoip2, the JS challenge still works — bots without JS are blocked.
# The tarpit is an optional enhancement that slows headless browser bots.
map $uri $tarpit_client {
default 0;
}'
fi
# Collect server_name values from nginx configs to build same-site referer map
local REFERER_ENTRIES=""
local _jsc_domain_seen=()
for _conf in /etc/nginx/conf.d/*.conf /etc/nginx/sites-enabled/*; do
[[ -f "$_conf" ]] || continue
while read -r _sn; do
for _d in $_sn; do
[[ "$_d" == "server_name" || "$_d" == ";" || "$_d" == "_" || "$_d" =~ ^[0-9] ]] && continue
_d="${_d%;}"
[[ " ${_jsc_domain_seen[*]:-} " == *" $_d "* ]] && continue
_jsc_domain_seen+=("$_d")
local _d_escaped="${_d//./\\.}"
REFERER_ENTRIES+=" ~^1:https?://${_d_escaped} 1;\n"
done
done < <(grep -oP '^\s*server_name\s+\K[^;]+;?' "$_conf" 2>/dev/null)
done
if [[ -z "$REFERER_ENTRIES" ]]; then
warn "No server_name values found — same-site image bypass will not work"
warn "Images behind the challenge may cause redirect loops for browsers"
fi
local MAP_CONTENT='# JS cookie challenge — allowed bots and cookie check
# Generated by nginx-security.sh js-challenge — https://mylinux.work
# Cookie: '"${COOKIE_NAME}"' Token: '"${COOKIE_VALUE:0:8}"'...
# Generated: '"$(date -Iseconds)"'
# ── Rate limit: challenge endpoint ───────────────────────────────────
# Real users hit the challenge once and keep the cookie. Headless bot farms
# spawn fresh browsers per request, hitting the challenge every time.
# Rate: '"${CHALLENGE_RATE}"'r/m with burst of '"${CHALLENGE_BURST}"' — excess gets 444.
limit_req_zone $binary_remote_addr zone=jschallenge:10m rate='"${CHALLENGE_RATE}"'r/m;
# Bots that legitimately identify themselves and should bypass the JS check
map $http_user_agent $is_allowed_bot {
default 0;
# Search engines
~*Googlebot 1;
~*bingbot 1;
~*Slurp 1;
~*DuckDuckBot 1;
~*DuckAssistBot 1;
~*Baiduspider 1;
~*YandexBot 1;
~*YandexFavicons 1;
~*Applebot 1;
~*Qwantbot 1;
~*Qwantify 1;
~*Bravebot 1;
~*kagi-fetcher 1;
~*Kagibot 1;
~*Yahoo! 1;
~*Yeti 1;
# Social media / link previews
~*facebookexternalhit 1;
~*Facebot 1;
~*Twitterbot 1;
~*LinkedInBot 1;
~*Slackbot 1;
~*Slack-ImgProxy 1;
~*Discordbot 1;
~*TelegramBot 1;
~*WhatsApp 1;
~*redditbot 1;
~*ArenaUnfurlBot 1;
# Feed readers
~*Feedly 1;
~*Miniflux 1;
~*FreshRSS 1;
~*NewsBlur 1;
~*Tiny\ Tiny\ RSS 1;
~*Inoreader 1;
~*NetNewsWire 1;
# Monitoring / uptime
~*UptimeRobot 1;
~*Pingdom 1;
~*StatusCake 1;
~*Blackbox-Exporter 1;
# AI answer bots (user-facing, not training crawlers)
~*OAI-SearchBot 1;
~*ChatGPT-User 1;
~*Claude-Web 1;
~*Claude-User 1;
~*MistralAI-User 1;
# Archive / research
~*archive\.org_bot 1;
# Apple Safari prefetch
~*safarifetcherd 1;
# Link checkers / validators
~*W3C_Validator 1;
~*W3C-checklink 1;
~*LinkChecker 1;
~*link-check 1;
# Decentralized search
~*yacybot 1;
# Add your own allowed bots below
}
# Validate the challenge cookie — exact token match
map '"${NGINX_COOKIE_VAR}"' $js_cookie_valid {
default 0;
"'"${COOKIE_VALUE}"'" 1;
}
# Detect requests to the challenge page, downloads, and static assets (prevent redirect loops)
map $uri $is_challenge_uri {
default 0;
"'"${CHALLENGE_PATH}"'" 1;
~^/downloads/ 1;
~*\.(css|js|woff2?)$ 1;
~*favicon 1;
~*apple-touch-icon 1;
}
# Detect image sub-resource requests with same-site referer (browser <img> loads)
# These bypass the challenge because: (a) images cannot execute JS challenges,
# and (b) the same-site referer proves the browser loaded a page from this domain.
# Direct image requests from scrapers (no referer or external referer) still get challenged.
map $uri $is_image_request {
default 0;
~*\.(png|jpe?g|gif|svg|webp|ico|avif)$ 1;
}
map "$is_image_request:$http_referer" $is_samesite_image {
default 0;
'"${REFERER_ENTRIES}"'}
# Combined check: need challenge if not allowed bot, no valid cookie, and not the challenge page
map "$is_allowed_bot:$js_cookie_valid:$is_challenge_uri:$is_samesite_image" $needs_js_challenge {
default 1;
"1:0:0:0" 0;
"1:0:0:1" 0;
"1:0:1:0" 0;
"1:0:1:1" 0;
"1:1:0:0" 0;
"1:1:0:1" 0;
"1:1:1:0" 0;
"1:1:1:1" 0;
"0:1:0:0" 0;
"0:1:0:1" 0;
"0:1:1:0" 0;
"0:1:1:1" 0;
"0:0:1:0" 0;
"0:0:1:1" 0;
"0:0:0:1" 0;
}
'"${TARPIT_MAP_BLOCK}"'
# Serve the challenge page
server {
listen 127.0.0.1:18444;
server_name _;
root /var/www/js-challenge;
location / {
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
add_header Pragma "no-cache" always;
try_files /challenge.html =404;
}
}'
if [[ "$DRY_RUN" == "true" ]]; then
echo " Would create: ${CHALLENGE_MAP}"
else
if [[ -f "$CHALLENGE_MAP" ]]; then
cp "$CHALLENGE_MAP" "${CHALLENGE_MAP}.bak.${TIMESTAMP}"
warn "Existing config backed up"
fi
echo "$MAP_CONTENT" > "$CHALLENGE_MAP"
info "Map config created: ${CHALLENGE_MAP}"
fi
# =====================================================
# Step 3: Inject JS challenge into server blocks
# =====================================================
step "Injecting JS challenge into server blocks"
local JSC_MARKER_START="# js-challenge-managed-start"
local JSC_MARKER_END="# js-challenge-managed-end"
local JSC_BLOCK=""
JSC_BLOCK+=" ${JSC_MARKER_START}\n"
JSC_BLOCK+=" location = ${CHALLENGE_PATH} {\n"
JSC_BLOCK+=" limit_req zone=jschallenge burst=${CHALLENGE_BURST} nodelay;\n"
JSC_BLOCK+=" limit_req_status 444;\n"
JSC_BLOCK+=" proxy_pass http://127.0.0.1:18444/;\n"
JSC_BLOCK+=" }\n"
JSC_BLOCK+="\n"
JSC_BLOCK+=" # JS cookie challenge — redirect non-JS visitors\n"
JSC_BLOCK+=" if (\$needs_js_challenge) {\n"
JSC_BLOCK+=" return 302 ${CHALLENGE_PATH}?r=\$request_uri&ref=\$http_referer;\n"
JSC_BLOCK+=" }\n"
if [[ "$GEOIP2_AVAILABLE" == "true" ]]; then
JSC_BLOCK+="\n"
JSC_BLOCK+=" # Tarpit headless Chrome bots from suspect GeoIP regions\n"
JSC_BLOCK+=" if (\$tarpit_client) {\n"
JSC_BLOCK+=" set \$limit_rate ${TARPIT_RATE};\n"
JSC_BLOCK+=" }\n"
fi
JSC_BLOCK+=" ${JSC_MARKER_END}"
mapfile -t configs < <(collect_configs "" "$CHALLENGE_MAP" "${CONF_DIR}/bot-block.conf")
if [[ ${#configs[@]} -eq 0 ]]; then
warn "No server block config files found"
else
local MODIFIED=0
for conf in "${configs[@]}"; do
if grep -q "$JSC_MARKER_START" "$conf" 2>/dev/null; then
warn "Already managed: ${conf} — skipping"
continue
fi
if [[ "$DRY_RUN" == "true" ]]; then
echo " Would inject into: ${conf}"
MODIFIED=$((MODIFIED + 1))
continue
fi
cp "$conf" "${conf}.bak.${TIMESTAMP}"
awk -v block="$JSC_BLOCK" '
/^\s*server\s*\{/ { in_server = 1; injected = 0 }
in_server && !injected && /^\s*location\s/ {
printf "%s\n", block
print ""
injected = 1
}
{ print }
' "$conf" > "${conf}.tmp"
mv "${conf}.tmp" "$conf"
info "Injected into: ${conf}"
MODIFIED=$((MODIFIED + 1))
done
if [[ $MODIFIED -eq 0 ]]; then
warn "No files modified (all already managed)"
fi
fi
# Step 4: Validate and reload
nginx_validate || exit 1
nginx_reload
# Summary
echo ""
echo -e "${BOLD}Done.${NC}"
echo ""
echo " Challenge map: ${CHALLENGE_MAP}"
echo " Challenge page: ${CHALLENGE_HTML}"
echo " State file: ${STATE_FILE}"
echo " Cookie name: ${COOKIE_NAME}"
echo " Cookie token: ${COOKIE_VALUE:0:8}... (32 hex chars)"
echo " Cookie TTL: ${COOKIE_MAX_AGE}s"
echo " Tarpit countries: ${TARPIT_COUNTRIES}"
echo " Tarpit rate: ${TARPIT_RATE} bytes/sec"
echo " Challenge rate: ${CHALLENGE_RATE}r/m (burst: ${CHALLENGE_BURST})"
echo ""
echo " To rotate credentials (invalidate bot-cached cookies):"
echo " sudo $(basename "$0") js-challenge"
echo ""
echo " To remove: sudo $(basename "$0") js-challenge --remove"
echo ""
echo " Test (bot without cookie gets redirected to challenge):"
echo " curl -o /dev/null -s -w '%{http_code}' https://yourdomain.com"
echo " Expected: 302"
echo ""
echo " Test (browser completes challenge — 302 → 200):"
echo " Open https://yourdomain.com in a browser"
echo " Expected: brief redirect then page loads normally"
echo ""
echo " Test (old static bypass no longer works):"
echo " curl -b '_bc=verified' -o /dev/null -s -w '%{http_code}' https://yourdomain.com"
echo " Expected: 302 (not 200 — old cookie is invalid)"
echo ""
echo " Test (rate limit on challenge endpoint):"
echo " for i in 1 2 3 4 5; do curl -o /dev/null -s -w \"\$i: %{http_code}\n\" https://yourdomain.com${CHALLENGE_PATH}; done"
echo " Expected: first 3 return 200, then 444 (rate limited)"
echo ""
echo " Test (allowed bot bypasses challenge):"
echo " curl -A 'Googlebot' -o /dev/null -s -w '%{http_code}' https://yourdomain.com"
echo " Expected: 200"
}
# =============================================================================
# SUBCOMMAND: block-head
# =============================================================================
cmd_block_head() {
local REMOVE=false
local SNIPPET_NAME="nginx.conf_block_head"
local SNIPPET_CONTENT='# Block HEAD request crawlers/scrapers
# Added by nginx-security.sh block-head
# Returns 444 (drop connection) — no response sent to bot
if ($request_method = HEAD) {
return 444;
}'
usage_block_head() {
cat <<EOF
Usage: sudo $(basename "$0") block-head [OPTIONS]
Block HTTP HEAD requests in Nginx by adding per-domain snippets that return
444 (drop connection) for HEAD method requests.
Works with HestiaCP — adds custom nginx config snippets to each domain's
server block via the conf/web include pattern.
On non-HestiaCP servers, displays the snippet for manual addition.
Options:
--dry-run Show what would be done without making changes
--remove Remove the block-head snippets from all domains
-h, --help Show this help
EOF
exit 0
}
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) DRY_RUN=true; shift ;;
--remove) REMOVE=true; shift ;;
-h|--help) usage_block_head ;;
*) echo "Unknown option: $1"; usage_block_head ;;
esac
done
require_root
require_nginx
# Check for HestiaCP
if ! command -v v-list-users &>/dev/null; then
echo ""
echo " HestiaCP not detected. Add the following inside each server block:"
echo ""
echo -e "${CYAN}${SNIPPET_CONTENT}${NC}"
echo ""
echo " This blocks HEAD request crawlers/scrapers by dropping the connection."
echo " 444 returns nothing — no headers, no body. Bots waste their connection."
return 0
fi
# --- Find all HestiaCP domains ---
_get_all_domain_dirs() {
local users
users=$(v-list-users plain 2>/dev/null | cut -f1)
for user in $users; do
local user_conf="/home/${user}/conf/web"
[[ -d "$user_conf" ]] || continue
for nginx_conf in "${user_conf}"/*/nginx.conf; do
[[ -f "$nginx_conf" ]] || continue
dirname "$nginx_conf"
done
done
}
# =====================================================
# Remove mode
# =====================================================
if [[ "$REMOVE" == "true" ]]; then
local removed=0
while IFS= read -r domain_dir; do
local snippet="${domain_dir}/${SNIPPET_NAME}"
local ssl_snippet="${domain_dir}/nginx.ssl.conf_block_head"
for f in "$snippet" "$ssl_snippet"; do
if [[ -f "$f" ]]; then
if [[ "$DRY_RUN" == "true" ]]; then
echo " Would remove: ${f}"
else
rm -f "$f"
info "Removed ${f}"
fi
((removed++)) || true
fi
done
done < <(_get_all_domain_dirs)
if [[ $removed -eq 0 ]]; then
info "No block-head snippets found — nothing to remove"
return 0
fi
if [[ "$DRY_RUN" == "true" ]]; then
echo " Would test and reload nginx"
return 0
fi
if nginx -t 2>/dev/null; then
systemctl reload nginx
info "nginx reloaded — HEAD requests are now allowed"
else
err "nginx config test failed after removal — check your config"
exit 1
fi
return 0
fi
# =====================================================
# Install mode
# =====================================================
local domain_dirs=()
while IFS= read -r dir; do
domain_dirs+=("$dir")
done < <(_get_all_domain_dirs)
if [[ ${#domain_dirs[@]} -eq 0 ]]; then
err "No HestiaCP web domains found"
exit 1
fi
info "Found ${#domain_dirs[@]} domain config(s)"
echo ""
local created=0
local skipped=0
local created_files=()
for domain_dir in "${domain_dirs[@]}"; do
for conf_type in "" ".ssl"; do
local snippet
if [[ -n "$conf_type" ]]; then
snippet="${domain_dir}/nginx${conf_type}.conf_block_head"
else
snippet="${domain_dir}/${SNIPPET_NAME}"
fi
local main_conf="${domain_dir}/nginx${conf_type}.conf"
[[ -f "$main_conf" ]] || continue
if [[ -f "$snippet" ]]; then
info "Already exists: ${snippet}"
((skipped++)) || true
continue
fi
if [[ "$DRY_RUN" == "true" ]]; then
echo " Would create: ${snippet}"
((created++)) || true
else
echo "$SNIPPET_CONTENT" > "$snippet"
created_files+=("$snippet")
info "Created ${snippet}"
((created++)) || true
fi
done
done
echo ""
if [[ $created -eq 0 && $skipped -gt 0 ]]; then
info "HEAD requests are already blocked on all domains"
return 0
fi
if [[ "$DRY_RUN" == "true" ]]; then
echo ""
echo "$SNIPPET_CONTENT"
echo ""
info "Would create ${created} snippet(s) (${skipped} already exist)"
info "Would test nginx config and reload"
return 0
fi
# Test nginx config
info "Testing nginx configuration..."
if nginx -t 2>&1; then
echo ""
info "Config test passed"
systemctl reload nginx
info "nginx reloaded — HEAD requests blocked on ${#domain_dirs[@]} domain(s) (444 drop)"
else
echo ""
err "Config test FAILED — rolling back all changes"
for f in "${created_files[@]}"; do
rm -f "$f"
err "Removed ${f}"
done
err "nginx was NOT reloaded — your site is unaffected"
exit 1
fi
echo ""
echo " Verify with: curl -I https://your-site.com"
echo " Expected: curl returns empty reply (connection dropped)"
echo " To undo: sudo $(basename "$0") block-head --remove"
}
# =============================================================================
# SUBCOMMAND: crowdsec
# =============================================================================
cmd_crowdsec() {
local ENROLLMENT_KEY=""
local SKIP_ENGINE=false
local SKIP_BOUNCER=false
local SKIP_APPSEC=false
local REMOVE=false
local ACQUIS_FILE="/etc/crowdsec/acquis.d/nginx.yaml"
usage_crowdsec() {
cat <<EOF
Usage: sudo $(basename "$0") crowdsec [OPTIONS]
Installs and configures CrowdSec engine, nginx lua bouncer, and standard nginx
log acquisition. Provides automated bot/attack detection and mitigation.
Get your enrollment key from https://app.crowdsec.net → Settings → Engines → Enroll.
Options:
--enrollment-key KEY CrowdSec Console enrollment key (optional)
--skip-engine Skip CrowdSec engine installation
--skip-bouncer Skip nginx lua bouncer installation
--skip-appsec Skip AppSec WAF component installation
--dry-run Show what would be done without making changes
--remove Remove CrowdSec bouncer and acquisition config
-h, --help Show this help
Examples:
sudo $(basename "$0") crowdsec
sudo $(basename "$0") crowdsec --enrollment-key clk1234abcd
sudo $(basename "$0") crowdsec --skip-appsec
sudo $(basename "$0") crowdsec --dry-run
sudo $(basename "$0") crowdsec --remove
EOF
exit 0
}
while [[ $# -gt 0 ]]; do
case "$1" in
--enrollment-key) ENROLLMENT_KEY="$2"; shift 2 ;;
--skip-engine) SKIP_ENGINE=true; shift ;;
--skip-bouncer) SKIP_BOUNCER=true; shift ;;
--skip-appsec) SKIP_APPSEC=true; shift ;;
--dry-run) DRY_RUN=true; shift ;;
--remove) REMOVE=true; shift ;;
-h|--help) usage_crowdsec ;;
*) echo "Unknown option: $1"; usage_crowdsec ;;
esac
done
require_root
require_nginx
# =====================================================
# REMOVE MODE
# =====================================================
if [[ "$REMOVE" == "true" ]]; then
step "Removing CrowdSec components"
if [[ "$DRY_RUN" == "true" ]]; then
echo " Would run: apt remove crowdsec-nginx-bouncer"
echo " Would remove: ${ACQUIS_FILE}"
echo " Would reload nginx"
echo ""
echo " Note: CrowdSec engine is NOT removed by default."
echo " To fully remove: apt remove crowdsec"
else
if dpkg -l crowdsec-nginx-bouncer &>/dev/null 2>&1; then
step "Removing nginx bouncer"
apt-get remove -y crowdsec-nginx-bouncer
info "Removed: crowdsec-nginx-bouncer"
else
warn "crowdsec-nginx-bouncer not installed"
fi
if [[ -f "$ACQUIS_FILE" ]]; then
rm -f "$ACQUIS_FILE"
info "Removed: ${ACQUIS_FILE}"
else
warn "Acquisition file not found: ${ACQUIS_FILE}"
fi
if nginx -t 2>&1; then
systemctl reload nginx
info "nginx reloaded"
else
err "nginx config test failed after removal"
exit 1
fi
if systemctl is-active --quiet crowdsec 2>/dev/null; then
systemctl restart crowdsec
info "CrowdSec restarted"
fi
fi
echo ""
echo -e "${BOLD}CrowdSec bouncer and acquisition removed.${NC}"
echo ""
echo " The CrowdSec engine is still installed."
echo " To fully remove: sudo apt remove crowdsec"
return 0
fi
# =====================================================
# Step 1: Install CrowdSec engine
# =====================================================
if [[ "$SKIP_ENGINE" == "true" ]]; then
warn "Skipping CrowdSec engine installation (--skip-engine)"
else
step "Installing CrowdSec engine"
if [[ "$DRY_RUN" == "true" ]]; then
echo " Would run: curl -s https://install.crowdsec.net | bash"
echo " Would run: apt install -y crowdsec"
else
if ! command -v cscli &>/dev/null; then
curl -s https://install.crowdsec.net | bash
apt-get install -y crowdsec
info "CrowdSec engine installed"
else
info "CrowdSec engine already installed"
fi
fi
# Install collections
step "Installing CrowdSec collections"
local collections=(
"crowdsecurity/nginx"
"crowdsecurity/http-cve"
"crowdsecurity/base-http-scenarios"
"crowdsecurity/sshd"
"crowdsecurity/linux"
)
for collection in "${collections[@]}"; do
if [[ "$DRY_RUN" == "true" ]]; then
echo " Would run: cscli collections install ${collection}"
else
cscli collections install "$collection" --force 2>/dev/null || true
info "Installed collection: ${collection}"
fi
done
fi
# =====================================================
# Step 2: Configure nginx log acquisition
# =====================================================
step "Configuring nginx log acquisition at ${ACQUIS_FILE}"
local ACQUIS_CONTENT
read -r -d '' ACQUIS_CONTENT <<'ACQUISEOF' || true
# Nginx log acquisition — generated by nginx-security.sh crowdsec
# Standard nginx access log
filenames:
- /var/log/nginx/access.log
labels:
type: nginx
---
# Standard nginx error log
filenames:
- /var/log/nginx/error.log
labels:
type: nginx
---
# Site-specific logs (sites-enabled virtual hosts)
filenames:
- /var/log/nginx/*access*.log
- /var/log/nginx/*error*.log
labels:
type: nginx
---
# System auth log (SSH brute-force detection)
filenames:
- /var/log/auth.log
labels:
type: syslog
ACQUISEOF
if [[ "$DRY_RUN" == "true" ]]; then
echo " Would create: ${ACQUIS_FILE}"
else
mkdir -p "$(dirname "$ACQUIS_FILE")"
echo "$ACQUIS_CONTENT" > "$ACQUIS_FILE"
info "Created: ${ACQUIS_FILE}"
fi
# =====================================================
# Step 3: Install nginx lua bouncer
# =====================================================
if [[ "$SKIP_BOUNCER" == "true" ]]; then
warn "Skipping nginx lua bouncer installation (--skip-bouncer)"
else
step "Installing nginx lua bouncer dependencies"
if [[ "$DRY_RUN" == "true" ]]; then
echo " Would run: apt install -y lua5.1 libnginx-mod-http-lua luarocks gettext-base lua-cjson"
echo " Would run: apt install -y crowdsec-nginx-bouncer"
else
apt-get install -y lua5.1 libnginx-mod-http-lua luarocks gettext-base lua-cjson 2>/dev/null || true
info "Lua dependencies installed"
step "Installing crowdsec-nginx-bouncer"
apt-get install -y crowdsec-nginx-bouncer
info "crowdsec-nginx-bouncer installed"
fi
fi
# =====================================================
# Step 4: Restart nginx
# =====================================================
step "Restarting nginx"
if [[ "$DRY_RUN" == "true" ]]; then
echo " Would run: systemctl restart nginx"
else
if nginx -t 2>&1; then
systemctl restart nginx
info "nginx restarted"
else
err "nginx config test failed"
exit 1
fi
fi
# =====================================================
# Step 5: AppSec WAF (optional)
# =====================================================
if [[ "$SKIP_APPSEC" == "true" ]]; then
warn "Skipping AppSec component (--skip-appsec)"
else
step "Installing AppSec collections"
local appsec_collections=(
"crowdsecurity/appsec-virtual-patching"
"crowdsecurity/appsec-generic-rules"
)
for collection in "${appsec_collections[@]}"; do
if [[ "$DRY_RUN" == "true" ]]; then
echo " Would run: cscli collections install ${collection}"
else
cscli collections install "$collection" --force 2>/dev/null || true
info "Installed collection: ${collection}"
fi
done
step "Adding AppSec acquisition block to ${ACQUIS_FILE}"
local APPSEC_BLOCK
read -r -d '' APPSEC_BLOCK <<'APPSECEOF' || true
---
# AppSec Component
listen_addr: 127.0.0.1:7422
appsec_config: crowdsecurity/appsec-default
name: nginx_appsec
source: appsec
labels:
type: appsec
APPSECEOF
if [[ "$DRY_RUN" == "true" ]]; then
echo " Would append AppSec block to: ${ACQUIS_FILE}"
else
echo "" >> "$ACQUIS_FILE"
echo "$APPSEC_BLOCK" >> "$ACQUIS_FILE"
info "AppSec acquisition block added to: ${ACQUIS_FILE}"
fi
fi
# =====================================================
# Step 6: Console enrollment (optional)
# =====================================================
if [[ -n "$ENROLLMENT_KEY" ]]; then
step "Enrolling in CrowdSec Console"
if [[ "$DRY_RUN" == "true" ]]; then
echo " Would run: cscli console enroll ${ENROLLMENT_KEY}"
echo " Would run: systemctl restart crowdsec"
else
cscli console enroll "$ENROLLMENT_KEY"
info "Enrolled in CrowdSec Console"
systemctl restart crowdsec
info "CrowdSec restarted"
fi
fi
# =====================================================
# Step 7: Restart CrowdSec to pick up acquisition
# =====================================================
step "Restarting CrowdSec"
if [[ "$DRY_RUN" == "true" ]]; then
echo " Would run: systemctl restart crowdsec"
else
systemctl restart crowdsec
info "CrowdSec restarted"
fi
# =====================================================
# Step 8: Verify
# =====================================================
step "Verifying installation"
if [[ "$DRY_RUN" == "true" ]]; then
echo " Would run: cscli bouncers list"
echo " Would run: nginx -t"
else
echo ""
echo " Registered bouncers:"
cscli bouncers list 2>/dev/null || warn "Could not list bouncers"
echo ""
if nginx -t 2>&1; then
info "nginx config valid"
else
err "nginx config test failed"
fi
fi
# =====================================================
# Summary
# =====================================================
echo ""
echo -e "${BOLD}Done.${NC}"
echo ""
echo " Acquisition: ${ACQUIS_FILE}"
if [[ "$SKIP_ENGINE" != "true" ]]; then
echo " Engine: CrowdSec installed"
fi
if [[ "$SKIP_BOUNCER" != "true" ]]; then
echo " Bouncer: crowdsec-nginx-bouncer installed"
fi
if [[ "$SKIP_APPSEC" != "true" ]]; then
echo " AppSec: WAF component configured"
fi
if [[ -n "$ENROLLMENT_KEY" ]]; then
echo " Console: Enrolled (accept in CrowdSec Console web UI)"
fi
echo ""
echo " Get enrollment key: https://app.crowdsec.net → Settings → Engines → Enroll"
echo ""
echo " Useful commands:"
echo " cscli decisions list — show active decisions (bans)"
echo " cscli alerts list — show recent alerts"
echo " cscli bouncers list — show registered bouncers"
echo " cscli metrics — show processing metrics"
echo " cscli hub update — update hub index"
echo ""
echo " To remove: sudo $(basename "$0") crowdsec --remove"
}
# =============================================================================
# SUBCOMMAND: status
# =============================================================================
cmd_status() {
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help)
echo "Usage: sudo $(basename "$0") status"
echo ""
echo "Show status of all security features."
exit 0
;;
*) echo "Unknown option: $1"; exit 1 ;;
esac
done
echo ""
echo -e "${BOLD}=== Nginx Security Status ===${NC}"
echo ""
# --- Bot-block ---
echo -e "${CYAN}Bot-block:${NC}"
local bot_map="/etc/nginx/conf.d/bot-block.conf"
if [[ -f "$bot_map" ]]; then
local bot_count
bot_count=$(grep -c '~\*' "$bot_map" 2>/dev/null || echo "0")
echo -e " Status: ${GREEN}Active${NC}"
echo " Map: ${bot_map}"
echo " Bot patterns: ${bot_count}"
else
echo -e " Status: ${YELLOW}Not configured${NC}"
fi
echo ""
# --- JS challenge ---
echo -e "${CYAN}JS Challenge:${NC}"
local jsc_map="/etc/nginx/conf.d/js-challenge.conf"
local jsc_state="/etc/nginx/js-challenge.env"
if [[ -f "$jsc_map" ]]; then
echo -e " Status: ${GREEN}Active${NC}"
echo " Map: ${jsc_map}"
if [[ -f "$jsc_state" ]]; then
local _cookie_name=""
_cookie_name=$(grep "^COOKIE_NAME=" "$jsc_state" 2>/dev/null | cut -d"'" -f2)
if [[ -n "$_cookie_name" ]]; then
echo " Cookie name: ${_cookie_name}"
fi
fi
else
echo -e " Status: ${YELLOW}Not configured${NC}"
fi
echo ""
# --- Block HEAD ---
echo -e "${CYAN}Block HEAD:${NC}"
local head_count=0
if command -v v-list-users &>/dev/null; then
head_count=$(find /home/*/conf/web/ -name "nginx.conf_block_head" 2>/dev/null | wc -l || echo "0")
fi
if [[ $head_count -gt 0 ]]; then
echo -e " Status: ${GREEN}Active${NC}"
echo " Snippets: ${head_count} domain(s)"
else
echo -e " Status: ${YELLOW}Not configured${NC}"
fi
echo ""
# --- CrowdSec ---
echo -e "${CYAN}CrowdSec:${NC}"
if command -v cscli &>/dev/null; then
if systemctl is-active --quiet crowdsec 2>/dev/null; then
echo -e " Engine: ${GREEN}Running${NC}"
else
echo -e " Engine: ${RED}Stopped${NC}"
fi
if dpkg -l crowdsec-nginx-bouncer &>/dev/null 2>&1; then
echo -e " Bouncer: ${GREEN}Installed${NC}"
local bouncer_count
bouncer_count=$(cscli bouncers list -o raw 2>/dev/null | tail -n +2 | wc -l || echo "0")
echo " Registered bouncers: ${bouncer_count}"
else
echo -e " Bouncer: ${YELLOW}Not installed${NC}"
fi
local decision_count
decision_count=$(cscli decisions list -o raw 2>/dev/null | tail -n +2 | wc -l || echo "0")
echo " Active decisions: ${decision_count}"
else
echo -e " Status: ${YELLOW}Not installed${NC}"
fi
echo ""
# --- Nginx ---
echo -e "${CYAN}Nginx:${NC}"
if systemctl is-active --quiet nginx 2>/dev/null; then
echo -e " Status: ${GREEN}Running${NC}"
else
echo -e " Status: ${RED}Stopped${NC}"
fi
if command -v nginx &>/dev/null; then
if nginx -t 2>/dev/null; then
echo -e " Config: ${GREEN}Valid${NC}"
else
echo -e " Config: ${RED}Invalid${NC}"
fi
fi
echo ""
}
# =============================================================================
# MAIN DISPATCH
# =============================================================================
case "${1:-}" in
bot-block) shift; cmd_bot_block "$@" ;;
js-challenge) shift; cmd_js_challenge "$@" ;;
block-head) shift; cmd_block_head "$@" ;;
crowdsec) shift; cmd_crowdsec "$@" ;;
status) shift; cmd_status "$@" ;;
-h|--help|"") usage_main ;;
*) err "Unknown command: $1"; usage_main ;;
esac