#!/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 </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 < "$STATE_FILE" </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=' Verifying ' 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 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 </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 </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