#!/bin/bash ################################################################################ # Script Name: hestia-security.sh # Version: 1.1 # Description: Unified HestiaCP/VestaCP/myVesta security toolkit. Consolidates # bot-blocking, JS cookie challenge, GeoIP enriched logging, and # CrowdSec bouncer setup into a single subcommand-based script # with shared helpers. # # 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 # geoip — GeoIP2 enriched logging with country/ASN data # crowdsec — CrowdSec engine + nginx lua bouncer + HestiaCP log acquisition # status — Show status of all security features # # Usage: # sudo ./hestia-security.sh bot-block [OPTIONS] # sudo ./hestia-security.sh js-challenge [OPTIONS] # sudo ./hestia-security.sh geoip [OPTIONS] # sudo ./hestia-security.sh crowdsec [OPTIONS] # sudo ./hestia-security.sh status # ################################################################################ set -euo pipefail # ============================================================================= # SHARED HELPERS # ============================================================================= # --- Colors --- RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m' CYAN='\033[0;36m' BOLD='\033[1m' NC='\033[0m' # --- 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" 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 } # --- Panel detection --- detect_panel() { if [[ -d "/usr/local/hestia/data/templates/web/nginx" ]]; then PANEL_TPL_DIR="/usr/local/hestia/data/templates/web/nginx" PANEL_NAME="HestiaCP" elif [[ -d "/usr/local/vesta/data/templates/web/nginx" ]]; then PANEL_TPL_DIR="/usr/local/vesta/data/templates/web/nginx" PANEL_NAME="VestaCP/myVesta" else err "Neither HestiaCP nor VestaCP/myVesta found" exit 1 fi info "Detected ${PANEL_NAME} (${PANEL_TPL_DIR})" } # --- 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, restore backup on failure --- nginx_validate() { local backup_file="${1:-}" 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" if [[ -n "$backup_file" && -f "$backup_file" ]]; then local original="${backup_file%.bak.*}" cp "$backup_file" "$original" warn "Restored: ${backup_file}" fi 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 } # --- Apply template to domain(s) --- apply_template() { local apply_user="$1" local apply_domain="$2" local apply_all="$3" local template_name="$4" if [[ -z "$apply_user" ]]; then return fi if ! command -v v-change-web-domain-proxy-tpl &>/dev/null; then err "v-change-web-domain-proxy-tpl not found" exit 1 fi if [[ "$apply_all" == "true" ]]; then step "Applying template to all domains for user: ${apply_user}" local domains domains=$(v-list-web-domains "$apply_user" plain 2>/dev/null | awk '{print $1}') if [[ -z "$domains" ]]; then warn "No domains found for user: ${apply_user}" else while IFS= read -r domain; do if [[ "$DRY_RUN" == "true" ]]; then echo " Would apply: v-change-web-domain-proxy-tpl ${apply_user} ${domain} ${template_name}" else v-change-web-domain-proxy-tpl "$apply_user" "$domain" "$template_name" info "Applied to: ${domain}" fi done <<< "$domains" fi else step "Applying template to: ${apply_domain}" if [[ "$DRY_RUN" == "true" ]]; then echo " Would apply: v-change-web-domain-proxy-tpl ${apply_user} ${apply_domain} ${template_name}" else v-change-web-domain-proxy-tpl "$apply_user" "$apply_domain" "$template_name" info "Applied to: ${apply_domain}" fi fi } # --- Create template with directive injection --- # Args: src dst label directive marker_check create_template() { local src="$1" dst="$2" label="$3" directive="$4" marker_check="$5" if [[ ! -f "$src" ]]; then warn "Source template not found: ${src} — skipping ${label}" return fi if [[ "$DRY_RUN" == "true" ]]; then echo " Would create: ${dst} (from ${src})" return fi if [[ -f "$dst" ]]; then cp "$dst" "${dst}.bak.${TIMESTAMP}" warn "Existing ${label} template backed up" fi # Check if the source already has the marker (avoid double-injection) if grep -q "$marker_check" "$src"; then cp "$src" "$dst" info "Created ${label} template: ${dst} (${marker_check} already present in base)" else # Insert directive before the first 'location' line awk -v block="$directive" ' !inserted && /^[[:space:]]*location[[:space:]]/ { print block print "" inserted = 1 } { print } ' "$src" > "$dst" info "Created ${label} template: ${dst}" fi } # --- Common template creation flow --- # Args: base_template template_name directive marker_check create_panel_templates() { local base_template="$1" local template_name="$2" local directive="$3" local marker_check="$4" # Resolve the base template — verify it exists local base_tpl="${PANEL_TPL_DIR}/${base_template}.tpl" if [[ ! -f "$base_tpl" ]]; then if [[ "$base_template" != "default" ]]; then warn "Base template '${base_template}' not found — falling back to 'default'" base_template="default" fi base_tpl="${PANEL_TPL_DIR}/${base_template}.tpl" if [[ ! -f "$base_tpl" ]]; then err "Default template not found: ${base_tpl}" exit 1 fi fi step "Creating custom ${PANEL_NAME} nginx templates (${template_name}) from ${base_template}" # Proxy templates create_template \ "${PANEL_TPL_DIR}/${base_template}.tpl" \ "${PANEL_TPL_DIR}/${template_name}.tpl" \ "HTTP (.tpl)" "$directive" "$marker_check" create_template \ "${PANEL_TPL_DIR}/${base_template}.stpl" \ "${PANEL_TPL_DIR}/${template_name}.stpl" \ "SSL (.stpl)" "$directive" "$marker_check" # php-fpm templates (if they exist for the base) if [[ -d "${PANEL_TPL_DIR}/php-fpm" ]]; then if [[ -f "${PANEL_TPL_DIR}/php-fpm/${base_template}.tpl" ]]; then create_template \ "${PANEL_TPL_DIR}/php-fpm/${base_template}.tpl" \ "${PANEL_TPL_DIR}/php-fpm/${template_name}.tpl" \ "php-fpm HTTP (.tpl)" "$directive" "$marker_check" create_template \ "${PANEL_TPL_DIR}/php-fpm/${base_template}.stpl" \ "${PANEL_TPL_DIR}/php-fpm/${template_name}.stpl" \ "php-fpm SSL (.stpl)" "$directive" "$marker_check" fi fi # Copy .sh hooks from the base template if they exist for ext in tpl stpl; do local base_sh="${PANEL_TPL_DIR}/${base_template}.${ext}.sh" local dst_sh="${PANEL_TPL_DIR}/${template_name}.${ext}.sh" if [[ -f "$base_sh" && ! -f "$dst_sh" ]]; then cp "$base_sh" "$dst_sh" info "Copied hook: ${dst_sh}" fi done } # ============================================================================= # MAIN USAGE # ============================================================================= usage_main() { cat </dev/null | tr '[:upper:]' '[:lower:]' | sort -u } # Bots previously in the builtin list that were intentionally removed. REMOVED_BOTS="~*oai-searchbot ~*claude-web" # Extract custom entries from existing map that are NOT in our built-in list # and NOT in the removed list get_custom_entries() { local file="$1" if [[ ! -f "$file" ]]; then return fi local builtin_patterns builtin_patterns=$(get_builtin_patterns) while IFS= read -r line; do [[ "$line" =~ ^[[:space:]]*# ]] && continue [[ "$line" =~ ^[[:space:]]*$ ]] && continue [[ "$line" =~ ^map ]] && continue [[ "$line" =~ ^\} ]] && continue [[ "$line" =~ default ]] && continue local pattern pattern=$(echo "$line" | grep -oP '~\*\S+|^\s*""' | tr '[:upper:]' '[:lower:]' | head -1) [[ -z "$pattern" ]] && continue if echo "$builtin_patterns" | grep -qxF "$pattern"; then continue fi if echo "$REMOVED_BOTS" | grep -qxF "$pattern"; then continue fi echo "$line" done < "$file" } # ===================================================== # Step 1: Create or update nginx map # ===================================================== step "Configuring bot-block map at ${MAP_FILE}" local CUSTOM_ENTRIES="" local ADDED_NEW=0 if [[ -f "$MAP_FILE" ]]; then CUSTOM_ENTRIES=$(get_custom_entries "$MAP_FILE") local existing existing=$(get_existing_patterns "$MAP_FILE") while IFS= read -r pattern; do [[ -z "$pattern" ]] && continue if ! echo "$existing" | grep -qxF "$pattern"; then ADDED_NEW=$((ADDED_NEW + 1)) fi done <<< "$(get_builtin_patterns)" if [[ -n "$CUSTOM_ENTRIES" ]]; then local custom_count custom_count=$(echo "$CUSTOM_ENTRIES" | wc -l) info "Found ${custom_count} custom bot entries — will preserve them" fi fi # Build the full map content local MAP_CONTENT="# Bot-blocking map for AI scrapers, SEO bots, and vulnerability scanners # Generated by hestia-security.sh bot-block — https://mylinux.work # Last updated: $(date '+%Y-%m-%d %H:%M:%S') map \$http_user_agent \$is_bad_bot { default 0; ${BOT_LIST}" if [[ -n "$CUSTOM_ENTRIES" ]]; then MAP_CONTENT="${MAP_CONTENT} # Custom entries (preserved from previous configuration) ${CUSTOM_ENTRIES}" fi MAP_CONTENT="${MAP_CONTENT} }" if [[ "$DRY_RUN" == "true" ]]; then if [[ -f "$MAP_FILE" ]]; then echo " Would update: ${MAP_FILE}" [[ -n "$CUSTOM_ENTRIES" ]] && echo " Would preserve: $(echo "$CUSTOM_ENTRIES" | wc -l) custom entries" [[ "$ADDED_NEW" -gt 0 ]] && echo " Would add: ${ADDED_NEW} new bot patterns" else echo " Would create: ${MAP_FILE}" fi else if [[ -f "$MAP_FILE" ]]; then cp "$MAP_FILE" "${MAP_FILE}.bak.$(date +%s)" if [[ "$ADDED_NEW" -gt 0 ]]; then info "Map updated: ${MAP_FILE} (${ADDED_NEW} new patterns added)" else info "Map updated: ${MAP_FILE} (already current)" fi else info "Map created: ${MAP_FILE}" fi echo "$MAP_CONTENT" > "$MAP_FILE" fi # ===================================================== # If --update-map-only, skip templates and just reload # ===================================================== if [[ "$UPDATE_MAP_ONLY" == "true" ]]; then step "Testing nginx configuration" if [[ "$DRY_RUN" == "true" ]]; then echo " Would run: nginx -t" else if nginx -t 2>&1; then info "nginx config valid" else err "nginx config test failed — restoring backup" local latest_bak latest_bak=$(ls -t "${MAP_FILE}.bak."* 2>/dev/null | head -1) if [[ -n "$latest_bak" ]]; then cp "$latest_bak" "$MAP_FILE" warn "Restored: ${latest_bak}" fi exit 1 fi fi nginx_reload echo "" echo -e "${BOLD}Done.${NC} Map updated — templates unchanged." echo " Map: ${MAP_FILE}" 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)" return 0 fi # ===================================================== # Step 2: Create custom Hestia templates # ===================================================== local BOT_BLOCK_DIRECTIVE=' # Bot blocking — added by hestia-security.sh bot-block if ($is_bad_bot) { return 444; } # Block broken srcset scrapers if ($request_uri ~* "%20[0-9]+w,https?://") { return 444; } # Block spoofed referers with fragment identifiers (real browsers strip these) if ($http_referer ~* "#") { return 444; } # Block non-GET/HEAD methods (static sites never need POST/PUT/DELETE) if ($request_method !~ ^(GET|HEAD)$ ) { return 444; } # Block empty-referer requests for images (headless bot image scraping) set $block_image_scrape 0; if ($uri ~* "\.(png|jpg|webp)$") { set $block_image_scrape 1; } if ($http_referer = "") { set $block_image_scrape "${block_image_scrape}1"; } if ($block_image_scrape = "11") { return 444; }' create_panel_templates "$BASE_TEMPLATE" "$TEMPLATE_NAME" "$BOT_BLOCK_DIRECTIVE" "is_bad_bot" # ===================================================== # Step 3: Validate nginx config # ===================================================== if ! nginx_validate; then err "Aborting — fix the config errors above" return 1 fi # ===================================================== # Step 4: Apply template (optional) # ===================================================== apply_template "$APPLY_USER" "$APPLY_DOMAIN" "$APPLY_ALL" "$TEMPLATE_NAME" # ===================================================== # Step 5: Reload nginx # ===================================================== nginx_reload # ===================================================== # Summary # ===================================================== echo "" echo -e "${BOLD}Done.${NC}" echo "" echo " Map: ${MAP_FILE}" echo " Base: ${BASE_TEMPLATE}" echo " Template: ${TEMPLATE_NAME} (.tpl + .stpl)" if [[ -n "$APPLY_USER" ]]; then if [[ "$APPLY_ALL" == "true" ]]; then echo " Applied: All domains for ${APPLY_USER}" else echo " Applied: ${APPLY_DOMAIN}" fi else echo "" echo " To apply to a domain:" echo " v-change-web-domain-proxy-tpl ${TEMPLATE_NAME}" echo "" echo " To apply to all domains for a user:" echo " $(basename "$0") bot-block --apply-all " fi echo "" echo " To add new bots later without touching templates:" echo " sudo $(basename "$0") bot-block --update-map-only" 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 TEMPLATE_NAME="default-jschallenge" local BASE_TEMPLATE="default" 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 APPLY_USER="" local APPLY_DOMAIN="" local APPLY_ALL=false local REMOVE=false local UPDATE_HTML_ONLY=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 COOKIE_VAR="" # --- Usage --- usage_js_challenge() { cat < "$STATE_FILE" <&2 exit 1 fi elif [[ "$REMOVE" != "true" ]]; then _jsc_generate_credentials 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 templates FIRST — they reference variables defined in the # map file, so deleting the map while templates still use those # variables would break nginx -t if [[ "$DRY_RUN" == "true" ]]; then echo " Would remove JS challenge templates from ${PANEL_TPL_DIR:-/usr/local/hestia/data/templates/web/nginx}" else detect_panel for ext in tpl stpl; do local tpl_file="${PANEL_TPL_DIR}/${TEMPLATE_NAME}.${ext}" [[ -f "$tpl_file" ]] && rm -f "$tpl_file" && info "Removed: ${tpl_file}" tpl_file="${PANEL_TPL_DIR}/php-fpm/${TEMPLATE_NAME}.${ext}" [[ -f "$tpl_file" ]] && rm -f "$tpl_file" && info "Removed: ${tpl_file}" 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, geoip, etc.)" fi echo "" echo -e "${BOLD}JS challenge removed.${NC}" echo "" echo " Note: You should also switch away from the JS challenge templates" echo " on your domains:" echo " v-change-web-domain-proxy-tpl " return 0 fi # --- Panel detection --- detect_panel 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 # ===================================================== # If --update-html-only, stop here # ===================================================== if [[ "$UPDATE_HTML_ONLY" == "true" ]]; then echo "" echo -e "${BOLD}Done.${NC} Challenge HTML updated — templates unchanged." echo " HTML: ${CHALLENGE_HTML}" echo " Cookie: ${COOKIE_NAME}=${COOKIE_VALUE:0:8}..." echo "" echo " No nginx reload needed — the HTML is served as a static file." return 0 fi # Save credentials for future --update-html-only runs _jsc_save_credentials # ===================================================== # Step 2: Create nginx map config # ===================================================== step "Creating JS challenge map at ${CHALLENGE_MAP}" local NGINX_COOKIE_VAR="\$cookie_${COOKIE_NAME}" # 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 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 hestia-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; } ' if [[ "$GEOIP2_AVAILABLE" == "true" ]]; then 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; }" fi MAP_CONTENT+="${TARPIT_MAP_BLOCK}" MAP_CONTENT+=' # 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: Create custom Hestia templates # ===================================================== local JS_CHALLENGE_DIRECTIVE=' # JS cookie challenge — added by hestia-security.sh js-challenge location = '"${CHALLENGE_PATH}"' { limit_req zone=jschallenge burst='"${CHALLENGE_BURST}"' nodelay; limit_req_status 444; proxy_pass http://127.0.0.1:18444/; } # Redirect non-JS visitors to challenge page (pass original referrer) if ($needs_js_challenge) { return 302 '"${CHALLENGE_PATH}"'?r=$request_uri&ref=$http_referer; }' if [[ "$GEOIP2_AVAILABLE" == "true" ]]; then JS_CHALLENGE_DIRECTIVE+=' # Tarpit headless Chrome bots from suspect GeoIP regions if ($tarpit_client) { set $limit_rate '"${TARPIT_RATE}"'; }' fi create_panel_templates "$BASE_TEMPLATE" "$TEMPLATE_NAME" "$JS_CHALLENGE_DIRECTIVE" "needs_js_challenge" # ===================================================== # Step 4: Validate nginx config # ===================================================== if ! nginx_validate; then err "Aborting — fix the config errors above" return 1 fi # ===================================================== # Step 5: Apply template (optional) # ===================================================== apply_template "$APPLY_USER" "$APPLY_DOMAIN" "$APPLY_ALL" "$TEMPLATE_NAME" # ===================================================== # Step 6: Reload nginx # ===================================================== 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 " Base template: ${BASE_TEMPLATE}" echo " Template: ${TEMPLATE_NAME} (.tpl + .stpl)" echo " Cookie name: ${COOKIE_NAME}" echo " Cookie token: ${COOKIE_VALUE:0:8}... (32 hex chars)" echo " Cookie TTL: ${COOKIE_MAX_AGE}s" echo " Challenge rate: ${CHALLENGE_RATE}r/m (burst: ${CHALLENGE_BURST})" if [[ -n "$APPLY_USER" ]]; then if [[ "$APPLY_ALL" == "true" ]]; then echo " Applied: All domains for ${APPLY_USER}" else echo " Applied: ${APPLY_DOMAIN}" fi else echo "" echo " To apply to a domain:" echo " v-change-web-domain-proxy-tpl ${TEMPLATE_NAME}" echo "" echo " To apply to all domains for a user:" echo " sudo $(basename "$0") js-challenge --apply-all " fi echo "" echo " To rotate credentials (invalidate bot-cached cookies):" echo " sudo $(basename "$0") js-challenge --base-template ${BASE_TEMPLATE}" 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: geoip # ============================================================================= cmd_geoip() { local GEOIP_CONF="/etc/GeoIP.conf" local GEOIP_DB_DIR="/usr/share/GeoIP" local NGINX_GEOIP_CONF="/etc/nginx/conf.d/geoip2.conf" local NGINX_LOG_CONF="/etc/nginx/conf.d/enriched-log-format.conf" local APACHE_LOG_CONF="/etc/apache2/conf-available/enriched-log-format.conf" local CRON_WEEKLY="/etc/cron.weekly/geoip-db-update" local CRON_MONTHLY="/etc/cron.monthly/geoip-db-update" local MARKER_START="# geoip-managed-start" local MARKER_END="# geoip-managed-end" local TEMPLATE_NAME="default-geoip" local BASE_TEMPLATE="default" local APPLY_USER="" local APPLY_DOMAIN="" local APPLY_ALL=false local REMOVE=false local SKIP_PACKAGES=false local USE_DBIP=false local ACCOUNT_ID="${MAXMIND_ACCOUNT_ID:-}" local LICENSE_KEY="${MAXMIND_LICENSE_KEY:-}" local APACHE_TPL_DIR="" usage_geoip() { cat </dev/null || true fi fi if nginx_validate; then nginx_reload if systemctl is-active --quiet apache2 2>/dev/null; then step "Reloading Apache" if [[ "$DRY_RUN" != "true" ]]; then systemctl reload apache2 info "Apache reloaded" fi fi else warn "nginx config has errors — restore .bak files or check other configs" fi echo "" echo -e "${BOLD}GeoIP2 configuration removed.${NC}" echo " Domains still using the ${TEMPLATE_NAME} template should be switched back." return 0 fi # --- Prompt for credentials if not using DB-IP --- if [[ "$USE_DBIP" != "true" ]]; then if [[ -z "$ACCOUNT_ID" ]]; then read -rp "MaxMind Account ID (or Ctrl+C and rerun with --db-ip): " ACCOUNT_ID fi if [[ -z "$LICENSE_KEY" ]]; then read -rp "MaxMind License Key: " LICENSE_KEY fi if [[ -z "$ACCOUNT_ID" || -z "$LICENSE_KEY" ]]; then err "MaxMind credentials required (or use --db-ip for no-signup alternative)" exit 1 fi fi # ===================================================== # Step 1: Install packages and GeoIP2 nginx module # ===================================================== local NGINX_MOD_DIR="/usr/lib/nginx/modules" local GEOIP2_MOD="${NGINX_MOD_DIR}/ngx_http_geoip2_module.so" local LOAD_MOD_CONF="/etc/nginx/modules-enabled/50-mod-http-geoip2.conf" if [[ "$SKIP_PACKAGES" == "true" ]]; then warn "Skipping package installation (--skip-packages)" else step "Installing build dependencies" local PACKAGES if [[ "$USE_DBIP" == "true" ]]; then PACKAGES="libmaxminddb0 libmaxminddb-dev mmdb-bin curl build-essential git libpcre2-dev libssl-dev zlib1g-dev" else PACKAGES="libmaxminddb0 libmaxminddb-dev mmdb-bin geoipupdate build-essential git libpcre2-dev libssl-dev zlib1g-dev" fi if [[ "$DRY_RUN" == "true" ]]; then echo " Would run: apt-get update && apt-get install -y ${PACKAGES}" else apt-get update -qq # shellcheck disable=SC2086 apt-get install -y $PACKAGES info "Packages installed: ${PACKAGES}" fi if [[ -f "$GEOIP2_MOD" ]]; then info "GeoIP2 module already exists: ${GEOIP2_MOD}" else step "Building ngx_http_geoip2_module for nginx $(nginx -v 2>&1 | grep -oP '[\d.]+')" if [[ "$DRY_RUN" == "true" ]]; then echo " Would compile ngx_http_geoip2_module.so" else local NGINX_VER NGINX_VER=$(nginx -v 2>&1 | grep -oP '[\d.]+') local BUILD_DIR BUILD_DIR=$(mktemp -d) cd "$BUILD_DIR" curl -fsSL "https://nginx.org/download/nginx-${NGINX_VER}.tar.gz" -o nginx.tar.gz tar xzf nginx.tar.gz git clone --depth 1 https://github.com/leev/ngx_http_geoip2_module.git cd "nginx-${NGINX_VER}" ./configure --with-compat --add-dynamic-module=../ngx_http_geoip2_module make modules mkdir -p "$NGINX_MOD_DIR" cp objs/ngx_http_geoip2_module.so "$NGINX_MOD_DIR/" cp objs/ngx_stream_geoip2_module.so "$NGINX_MOD_DIR/" 2>/dev/null || true cd / rm -rf "$BUILD_DIR" info "Built and installed: ${GEOIP2_MOD}" fi fi if [[ ! -f "$LOAD_MOD_CONF" ]] && [[ "$DRY_RUN" != "true" ]]; then mkdir -p "$(dirname "$LOAD_MOD_CONF")" echo "load_module ${GEOIP2_MOD};" > "$LOAD_MOD_CONF" info "Created: ${LOAD_MOD_CONF}" fi fi # ===================================================== # Step 2: Write GeoIP.conf (MaxMind only) # ===================================================== if [[ "$USE_DBIP" != "true" ]]; then step "Writing MaxMind configuration to ${GEOIP_CONF}" local GEOIP_CONF_CONTENT="${MARKER_START} AccountID ${ACCOUNT_ID} LicenseKey ${LICENSE_KEY} EditionIDs GeoLite2-City GeoLite2-ASN GeoLite2-Country DatabaseDirectory ${GEOIP_DB_DIR} ${MARKER_END}" if [[ "$DRY_RUN" == "true" ]]; then echo " Would create: ${GEOIP_CONF}" else backup_file "$GEOIP_CONF" echo "$GEOIP_CONF_CONTENT" > "$GEOIP_CONF" chmod 600 "$GEOIP_CONF" info "Created: ${GEOIP_CONF}" fi fi # ===================================================== # Step 3: Download GeoIP databases # ===================================================== if [[ "$USE_DBIP" == "true" ]]; then step "Downloading DB-IP free databases to ${GEOIP_DB_DIR}" local DBIP_MONTH DBIP_MONTH=$(date +%Y-%m) if [[ "$DRY_RUN" == "true" ]]; then echo " Would download: dbip-country-lite-${DBIP_MONTH}.mmdb.gz" echo " Would download: dbip-asn-lite-${DBIP_MONTH}.mmdb.gz" else mkdir -p "$GEOIP_DB_DIR" cd "$GEOIP_DB_DIR" curl -fsSL "https://download.db-ip.com/free/dbip-country-lite-${DBIP_MONTH}.mmdb.gz" -o dbip-country.mmdb.gz gunzip -f dbip-country.mmdb.gz mv dbip-country.mmdb GeoLite2-City.mmdb info "Downloaded: DB-IP Country → GeoLite2-City.mmdb" curl -fsSL "https://download.db-ip.com/free/dbip-asn-lite-${DBIP_MONTH}.mmdb.gz" -o dbip-asn.mmdb.gz gunzip -f dbip-asn.mmdb.gz mv dbip-asn.mmdb GeoLite2-ASN.mmdb info "Downloaded: DB-IP ASN → GeoLite2-ASN.mmdb" cd - >/dev/null fi else step "Downloading MaxMind GeoIP2 databases to ${GEOIP_DB_DIR}" if [[ "$DRY_RUN" == "true" ]]; then echo " Would run: mkdir -p ${GEOIP_DB_DIR}" echo " Would run: geoipupdate" else mkdir -p "$GEOIP_DB_DIR" geoipupdate -v info "GeoIP2 databases downloaded" fi fi # ===================================================== # Step 4: Create nginx geoip2 config in conf.d # ===================================================== step "Creating nginx GeoIP2 config at ${NGINX_GEOIP_CONF}" local NGINX_GEOIP_CONTENT read -r -d '' NGINX_GEOIP_CONTENT < "$NGINX_GEOIP_CONF" info "Created: ${NGINX_GEOIP_CONF}" fi # ===================================================== # Step 5: Create enriched log format in conf.d # ===================================================== step "Creating enriched log format at ${NGINX_LOG_CONF}" local NGINX_LOG_CONTENT read -r -d '' NGINX_LOG_CONTENT < "$NGINX_LOG_CONF" info "Created: ${NGINX_LOG_CONF}" fi # ===================================================== # Step 5b: Create Apache enriched log format # ===================================================== if [[ -d "/etc/apache2/conf-available" ]]; then step "Creating Apache enriched log format at ${APACHE_LOG_CONF}" local APACHE_LOG_CONTENT read -r -d '' APACHE_LOG_CONTENT <s %b \"%{Referer}i\" \"%{User-Agent}i\" %{X-GeoIP-Country}i \"%{X-GeoIP-ASN}i\"" enriched ${MARKER_END} ALOGCONF if [[ "$DRY_RUN" == "true" ]]; then echo " Would create: ${APACHE_LOG_CONF}" echo " Would enable: a2enconf enriched-log-format" else backup_file "$APACHE_LOG_CONF" echo "$APACHE_LOG_CONTENT" > "$APACHE_LOG_CONF" a2enconf -q enriched-log-format 2>/dev/null || true info "Created and enabled: ${APACHE_LOG_CONF}" fi else warn "Apache conf-available directory not found — skipping Apache log format" fi # ===================================================== # Step 6: Create custom Hestia templates # ===================================================== # --- Hestia Apache template patching --- if [[ -d "/usr/local/hestia/data/templates/web/apache2/php-fpm" ]]; then APACHE_TPL_DIR="/usr/local/hestia/data/templates/web/apache2/php-fpm" elif [[ -d "/usr/local/hestia/data/templates/web/apache2" ]]; then APACHE_TPL_DIR="/usr/local/hestia/data/templates/web/apache2" fi _geoip_create_apache_template() { local src="$1" dst="$2" label="$3" if [[ ! -f "$src" ]]; then warn "Source Apache template not found: ${src} — skipping ${label}" return fi if [[ "$DRY_RUN" == "true" ]]; then echo " Would create: ${dst} (from ${src})" return fi backup_file "$dst" local APACHE_ENRICHED_FORMAT=' LogFormat "%h %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-Agent}i\" %{X-GeoIP-Country}i \"%{X-GeoIP-ASN}i\"" enriched' sed 's/\(CustomLog.*\.log\) combined/\1 enriched/g' "$src" | \ awk -v fmt="$APACHE_ENRICHED_FORMAT" ' !injected && /CustomLog/ { print fmt injected = 1 } { print } ' > "$dst" if grep -q "enriched" "$dst"; then info "Created ${label} template: ${dst}" else cp "$src" "$dst" warn "No 'combined' format found in ${src} — copied unchanged" fi } if [[ -n "$APACHE_TPL_DIR" ]]; then local APACHE_BASE="$BASE_TEMPLATE" if [[ ! -f "${APACHE_TPL_DIR}/${APACHE_BASE}.tpl" ]]; then APACHE_BASE="default" warn "Apache base template '${BASE_TEMPLATE}' not found — falling back to 'default'" fi step "Creating custom Apache templates (${TEMPLATE_NAME}) from ${APACHE_BASE}" _geoip_create_apache_template \ "${APACHE_TPL_DIR}/${APACHE_BASE}.tpl" \ "${APACHE_TPL_DIR}/${TEMPLATE_NAME}.tpl" \ "Apache HTTP (.tpl)" _geoip_create_apache_template \ "${APACHE_TPL_DIR}/${APACHE_BASE}.stpl" \ "${APACHE_TPL_DIR}/${TEMPLATE_NAME}.stpl" \ "Apache SSL (.stpl)" fi # --- Nginx template patching --- _geoip_create_nginx_template() { local src="$1" dst="$2" label="$3" if [[ ! -f "$src" ]]; then warn "Source template not found: ${src} — skipping ${label}" return fi if [[ "$DRY_RUN" == "true" ]]; then echo " Would create: ${dst} (from ${src})" return fi backup_file "$dst" local has_proxy_headers has_proxy_headers=$(grep -c 'proxy_set_header' "$src" || true) sed 's/\(access_log.*\.log\) combined;/\1 enriched;/g' "$src" | \ awk -v d='$' -v need_std="$has_proxy_headers" ' !injected && /proxy_pass/ { print if (need_std == 0) { print "\t\tproxy_set_header Host " d "host;" print "\t\tproxy_set_header X-Real-IP " d "remote_addr;" print "\t\tproxy_set_header X-Forwarded-For " d "proxy_add_x_forwarded_for;" print "\t\tproxy_set_header X-Forwarded-Proto " d "scheme;" } print "\t\tproxy_set_header X-GeoIP-Country " d "geoip2_country_code;" print "\t\tproxy_set_header X-GeoIP-ASN " d "geoip2_asn_org;" injected = 1 next } { print } ' > "$dst" if grep -q "enriched" "$dst"; then info "Created ${label} template: ${dst} (log format updated)" else warn "No 'combined' log format found in ${src} — format not changed" warn " Manually change access_log format to 'enriched' in ${dst}" fi if grep -q "proxy_set_header X-GeoIP-Country" "$dst"; then info "Injected GeoIP proxy headers into ${dst}" else warn "No proxy_pass found in ${src} — GeoIP headers not injected" fi } step "Creating custom ${PANEL_NAME} templates (${TEMPLATE_NAME}) from ${BASE_TEMPLATE}" # Resolve the base template local BASE_TPL="${PANEL_TPL_DIR}/${BASE_TEMPLATE}.tpl" if [[ ! -f "$BASE_TPL" ]]; then if [[ "$BASE_TEMPLATE" != "default" ]]; then warn "Base template '${BASE_TEMPLATE}' not found — falling back to 'default'" BASE_TEMPLATE="default" fi BASE_TPL="${PANEL_TPL_DIR}/${BASE_TEMPLATE}.tpl" if [[ ! -f "$BASE_TPL" ]]; then err "Default template not found: ${BASE_TPL}" exit 1 fi fi # Proxy templates (nginx as proxy for apache) _geoip_create_nginx_template \ "${PANEL_TPL_DIR}/${BASE_TEMPLATE}.tpl" \ "${PANEL_TPL_DIR}/${TEMPLATE_NAME}.tpl" \ "proxy HTTP (.tpl)" _geoip_create_nginx_template \ "${PANEL_TPL_DIR}/${BASE_TEMPLATE}.stpl" \ "${PANEL_TPL_DIR}/${TEMPLATE_NAME}.stpl" \ "proxy SSL (.stpl)" # php-fpm templates (nginx standalone) if [[ -d "${PANEL_TPL_DIR}/php-fpm" ]]; then if [[ -f "${PANEL_TPL_DIR}/php-fpm/${BASE_TEMPLATE}.tpl" ]]; then _geoip_create_nginx_template \ "${PANEL_TPL_DIR}/php-fpm/${BASE_TEMPLATE}.tpl" \ "${PANEL_TPL_DIR}/php-fpm/${TEMPLATE_NAME}.tpl" \ "php-fpm HTTP (.tpl)" _geoip_create_nginx_template \ "${PANEL_TPL_DIR}/php-fpm/${BASE_TEMPLATE}.stpl" \ "${PANEL_TPL_DIR}/php-fpm/${TEMPLATE_NAME}.stpl" \ "php-fpm SSL (.stpl)" fi fi # Copy .sh hook if base template has one for tpl_dir in "${PANEL_TPL_DIR}" "${PANEL_TPL_DIR}/php-fpm"; do if [[ -f "${tpl_dir}/${BASE_TEMPLATE}.sh" ]] && [[ ! -f "${tpl_dir}/${TEMPLATE_NAME}.sh" ]]; then if [[ "$DRY_RUN" == "true" ]]; then echo " Would copy: ${tpl_dir}/${BASE_TEMPLATE}.sh → ${tpl_dir}/${TEMPLATE_NAME}.sh" else cp "${tpl_dir}/${BASE_TEMPLATE}.sh" "${tpl_dir}/${TEMPLATE_NAME}.sh" info "Copied hook: ${tpl_dir}/${TEMPLATE_NAME}.sh" fi fi done # ===================================================== # Step 7: Create cron job for database updates # ===================================================== local CRON_FILE CRON_CONTENT if [[ "$USE_DBIP" == "true" ]]; then CRON_FILE="$CRON_MONTHLY" step "Creating monthly DB-IP update script at ${CRON_FILE}" read -r -d '' CRON_CONTENT < GeoLite2-City.mmdb && \\ curl -fsSL "https://download.db-ip.com/free/dbip-asn-lite-\${MONTH}.mmdb.gz" | gunzip > GeoLite2-ASN.mmdb && \\ logger -t dbip-update "DB-IP databases updated" CRONEOF else CRON_FILE="$CRON_WEEKLY" step "Creating weekly geoipupdate script at ${CRON_FILE}" read -r -d '' CRON_CONTENT <&1 | logger -t geoipupdate CRONEOF fi if [[ "$DRY_RUN" == "true" ]]; then echo " Would create: ${CRON_FILE}" else backup_file "$CRON_FILE" echo "$CRON_CONTENT" > "$CRON_FILE" chmod 755 "$CRON_FILE" info "Created: ${CRON_FILE}" fi # ===================================================== # Step 8: Validate nginx config # ===================================================== if ! nginx_validate; then err "Aborting — fix the config errors above" return 1 fi # ===================================================== # Step 9: Apply template (optional) # ===================================================== apply_template "$APPLY_USER" "$APPLY_DOMAIN" "$APPLY_ALL" "$TEMPLATE_NAME" # Apply Apache backend template (if Apache templates were created) if [[ -n "$APPLY_USER" && -n "$APACHE_TPL_DIR" ]] && [[ -f "${APACHE_TPL_DIR}/${TEMPLATE_NAME}.tpl" ]]; then if command -v v-change-web-domain-tpl &>/dev/null; then if [[ "$APPLY_ALL" == "true" ]]; then step "Applying Apache backend template to all domains for user: ${APPLY_USER}" local domains domains=$(v-list-web-domains "$APPLY_USER" plain 2>/dev/null | awk '{print $1}') while IFS= read -r domain; do if [[ "$DRY_RUN" == "true" ]]; then echo " Would apply: v-change-web-domain-tpl ${APPLY_USER} ${domain} ${TEMPLATE_NAME}" else v-change-web-domain-tpl "$APPLY_USER" "$domain" "$TEMPLATE_NAME" info "Apache template applied to: ${domain}" fi done <<< "$domains" else step "Applying Apache backend template to: ${APPLY_DOMAIN}" if [[ "$DRY_RUN" == "true" ]]; then echo " Would apply: v-change-web-domain-tpl ${APPLY_USER} ${APPLY_DOMAIN} ${TEMPLATE_NAME}" else v-change-web-domain-tpl "$APPLY_USER" "$APPLY_DOMAIN" "$TEMPLATE_NAME" info "Apache template applied to: ${APPLY_DOMAIN}" fi fi fi fi # ===================================================== # Step 10: Reload nginx and Apache # ===================================================== step "Reloading web servers" if [[ "$DRY_RUN" == "true" ]]; then echo " Would run: systemctl reload nginx" echo " Would run: systemctl reload apache2" else systemctl reload nginx info "nginx reloaded" if systemctl is-active --quiet apache2 2>/dev/null; then systemctl reload apache2 info "Apache reloaded" fi fi # ===================================================== # Summary # ===================================================== echo "" echo -e "${BOLD}Done.${NC}" echo "" echo " GeoIP config: ${NGINX_GEOIP_CONF}" echo " Log format: ${NGINX_LOG_CONF}" echo " Template: ${TEMPLATE_NAME} (.tpl + .stpl)" echo " Base: ${BASE_TEMPLATE}" echo " DB updates: ${CRON_FILE}" if [[ "$USE_DBIP" == "true" ]]; then echo " DB source: DB-IP Lite (no signup)" else echo " DB source: MaxMind GeoLite2" fi if [[ -n "$APPLY_USER" ]]; then if [[ "$APPLY_ALL" == "true" ]]; then echo " Applied: All domains for ${APPLY_USER}" else echo " Applied: ${APPLY_DOMAIN}" fi else echo "" echo " To apply to a domain:" echo " v-change-web-domain-proxy-tpl ${TEMPLATE_NAME}" echo "" echo " To apply to all domains for a user:" echo " sudo $(basename "$0") geoip --apply-all " fi echo "" echo " To remove: sudo $(basename "$0") geoip --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/hestiacp.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 HestiaCP log acquisition # ===================================================== step "Configuring HestiaCP log acquisition at ${ACQUIS_FILE}" local ACQUIS_CONTENT read -r -d '' ACQUIS_CONTENT <<'ACQUISEOF' || true # HestiaCP log acquisition — generated by hestia-security.sh crowdsec # nginx domain access logs filenames: - /var/log/nginx/domains/*.log labels: type: nginx --- # nginx domain error logs filenames: - /var/log/nginx/domains/*.error.log labels: type: nginx --- # HestiaCP panel nginx logs filenames: - /var/log/hestia/nginx-access.log - /var/log/hestia/nginx-error.log labels: type: nginx --- # System auth log filenames: - /var/log/auth.log labels: type: syslog --- # Exim mail log filenames: - /var/log/exim4/mainlog 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 nginx 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 nginx 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 # Install nginx-proxy parser for dual-stack step "Installing nginx-proxy parser for dual-stack setups" if [[ "$DRY_RUN" == "true" ]]; then echo " Would run: cscli parsers install crowdsecurity/nginx-proxy" else cscli parsers install crowdsecurity/nginx-proxy --force 2>/dev/null || true info "Installed parser: crowdsecurity/nginx-proxy" 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: hestiacp_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 " 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}=== HestiaCP Security Status ===${NC}" echo "" # --- Panel detection --- echo -e "${CYAN}Panel:${NC}" if [[ -d "/usr/local/hestia/data/templates/web/nginx" ]]; then echo " Detected: HestiaCP" echo " Templates: /usr/local/hestia/data/templates/web/nginx" local tpl_dir="/usr/local/hestia/data/templates/web/nginx" elif [[ -d "/usr/local/vesta/data/templates/web/nginx" ]]; then echo " Detected: VestaCP/myVesta" echo " Templates: /usr/local/vesta/data/templates/web/nginx" local tpl_dir="/usr/local/vesta/data/templates/web/nginx" else echo " Not detected (neither HestiaCP nor VestaCP/myVesta found)" local tpl_dir="" fi 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 # shellcheck source=/dev/null local _jsc_cookie_name="" _jsc_cookie_name=$(grep "^COOKIE_NAME=" "$jsc_state" 2>/dev/null | cut -d"'" -f2) if [[ -n "$_jsc_cookie_name" ]]; then echo " Cookie name: ${_jsc_cookie_name}" fi fi else echo -e " Status: ${YELLOW}Not configured${NC}" fi echo "" # --- GeoIP --- echo -e "${CYAN}GeoIP:${NC}" local geoip_conf="/etc/nginx/conf.d/geoip2.conf" if [[ -f "$geoip_conf" ]]; then echo -e " Status: ${GREEN}Active${NC}" echo " Config: ${geoip_conf}" # Determine DB source if [[ -f "/etc/cron.monthly/geoip-db-update" ]]; then echo " DB source: DB-IP Lite (monthly updates)" elif [[ -f "/etc/cron.weekly/geoip-db-update" ]]; then echo " DB source: MaxMind GeoLite2 (weekly updates)" else echo " DB source: Unknown (no update cron found)" fi # Show DB age local city_db="/usr/share/GeoIP/GeoLite2-City.mmdb" if [[ -f "$city_db" ]]; then local db_date db_date=$(stat -c '%y' "$city_db" 2>/dev/null | cut -d' ' -f1) echo " DB date: ${db_date}" fi 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 "" # --- Custom templates --- if [[ -n "$tpl_dir" && -d "$tpl_dir" ]]; then echo -e "${CYAN}Custom templates:${NC}" local found_tpl=false for tpl_name in default-botblock default-jschallenge default-geoip geoip-botblock geoip-botblock-jsc; do if [[ -f "${tpl_dir}/${tpl_name}.tpl" ]]; then echo " ✓ ${tpl_name}" found_tpl=true fi done if [[ "$found_tpl" == "false" ]]; then echo " No custom security templates found" fi echo "" fi } # ============================================================================= # MAIN DISPATCH # ============================================================================= case "${1:-}" in bot-block) shift; cmd_bot_block "$@" ;; js-challenge) shift; cmd_js_challenge "$@" ;; geoip) shift; cmd_geoip "$@" ;; crowdsec) shift; cmd_crowdsec "$@" ;; status) shift; cmd_status "$@" ;; -h|--help|"") usage_main ;; *) err "Unknown command: $1"; usage_main ;; esac