a1a17e81a1
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.
1803 lines
61 KiB
Bash
Executable File
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
|