#!/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 <<EOF
Usage: sudo $(basename "$0") COMMAND [OPTIONS]

Unified HestiaCP/VestaCP/myVesta security toolkit.

Commands:
  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

Run '$(basename "$0") COMMAND --help' for subcommand options.
EOF
    exit 0
}

# =============================================================================
# SUBCOMMAND: bot-block
# =============================================================================

cmd_bot_block() {
    local TEMPLATE_NAME="default-botblock"
    local BASE_TEMPLATE="default"
    local MAP_FILE="${CONF_DIR}/bot-block.conf"
    local APPLY_USER=""
    local APPLY_DOMAIN=""
    local APPLY_ALL=false
    local UPDATE_MAP_ONLY=false

    # --- Usage ---
    usage_bot_block() {
        cat <<EOF
Usage: sudo $(basename "$0") bot-block [OPTIONS]

Creates nginx bot-blocking map and custom HestiaCP / VestaCP / myVesta nginx templates.

Options:
  --template-name NAME   Custom template name (default: default-botblock)
  --base-template NAME   Template to build on (default: default)
                         Use this to stack on existing templates (e.g., default-geoip)
  --apply USER DOMAIN    Apply template to a specific domain after creation
  --apply-all USER       Apply template to all domains for a user
  --update-map-only      Only update the bot map — do not touch templates
  --dry-run              Show what would be done without making changes
  -h, --help             Show this help

Examples:
  sudo $(basename "$0") bot-block
  sudo $(basename "$0") bot-block --apply admin example.com
  sudo $(basename "$0") bot-block --apply-all admin
  sudo $(basename "$0") bot-block --base-template default-geoip --template-name geoip-botblock
  sudo $(basename "$0") bot-block --update-map-only
EOF
        exit 0
    }

    # --- Argument parsing ---
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --template-name)   TEMPLATE_NAME="$2"; shift 2 ;;
            --base-template)   BASE_TEMPLATE="$2"; shift 2 ;;
            --apply)           APPLY_USER="$2"; APPLY_DOMAIN="$3"; shift 3 ;;
            --apply-all)       APPLY_USER="$2"; APPLY_ALL=true; shift 2 ;;
            --update-map-only) UPDATE_MAP_ONLY=true; shift ;;
            --dry-run)         DRY_RUN=true; shift ;;
            -h|--help)         usage_bot_block ;;
            *)                 echo "Unknown option: $1"; usage_bot_block ;;
        esac
    done

    require_root
    require_nginx

    # Only detect panel if we need templates
    if [[ "$UPDATE_MAP_ONLY" == "false" ]]; then
        detect_panel
    fi

    # =====================================================
    # Bot list — single source of truth
    # =====================================================
    read -r -d '' BOT_LIST <<'BOTLIST' || true
    # 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;
BOTLIST

    # =====================================================
    # Bot map helper functions
    # =====================================================

    # Extract bot patterns from the built-in list (lowercase for comparison)
    get_builtin_patterns() {
        echo "$BOT_LIST" | grep -oP '~\*\S+|^    ""' | tr '[:upper:]' '[:lower:]' | sort -u
    }

    # Extract bot patterns from an existing map file (lowercase for comparison)
    get_existing_patterns() {
        local file="$1"
        grep -oP '~\*\S+|^\s*""' "$file" 2>/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 <user> <domain> ${TEMPLATE_NAME}"
        echo ""
        echo "  To apply to all domains for a user:"
        echo "    $(basename "$0") bot-block --apply-all <user>"
    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 <<EOF
Usage: sudo $(basename "$0") js-challenge [OPTIONS]

Adds a JavaScript cookie challenge to nginx on HestiaCP / VestaCP / myVesta.
Visitors that don't execute JavaScript (headless scrapers, curl-based bots) are
silently dropped. Legitimate search engine crawlers are whitelisted by user agent.

Options:
  --template-name NAME   Custom template name (default: default-jschallenge)
  --base-template NAME   Template to build on (default: default)
                         Use this to stack on existing templates (e.g., default-botblock,
                         geoip-botblock)
  --apply USER DOMAIN    Apply template to a specific domain after creation
  --apply-all USER       Apply template to all domains for a user
  --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)
  --dry-run              Show what would be done without making changes
  --update-html-only     Regenerate the challenge HTML page only — no template changes
                         (reuses existing cookie name and token from state file)
  --remove               Remove challenge config, HTML, and templates
  -h, --help             Show this help

Examples:
  sudo $(basename "$0") js-challenge
  sudo $(basename "$0") js-challenge --update-html-only
  sudo $(basename "$0") js-challenge --dry-run
  sudo $(basename "$0") js-challenge --base-template default-botblock
  sudo $(basename "$0") js-challenge --base-template geoip-botblock --template-name geoip-botblock-jsc
  sudo $(basename "$0") js-challenge --apply admin example.com
  sudo $(basename "$0") js-challenge --apply-all admin
  sudo $(basename "$0") js-challenge --remove
EOF
        exit 0
    }

    # --- Argument parsing ---
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --template-name)   TEMPLATE_NAME="$2"; shift 2 ;;
            --base-template)   BASE_TEMPLATE="$2"; shift 2 ;;
            --apply)           APPLY_USER="$2"; APPLY_DOMAIN="$3"; shift 3 ;;
            --apply-all)       APPLY_USER="$2"; APPLY_ALL=true; shift 2 ;;
            --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 ;;
            --dry-run)         DRY_RUN=true; shift ;;
            --update-html-only) UPDATE_HTML_ONLY=true; shift ;;
            --remove)          REMOVE=true; shift ;;
            -h|--help)         usage_js_challenge ;;
            *)                 echo "Unknown option: $1"; usage_js_challenge ;;
        esac
    done

    require_root

    # =====================================================
    # Generate or load cookie credentials
    # =====================================================
    _jsc_generate_credentials() {
        COOKIE_NAME="bc$(openssl rand -hex 1)"
        COOKIE_VALUE="$(openssl rand -hex 16)"
        COOKIE_VAR="\$cookie_${COOKIE_NAME}"
    }

    _jsc_save_credentials() {
        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
    }

    _jsc_load_credentials() {
        if [[ -f "$STATE_FILE" ]]; then
            # shellcheck source=/dev/null
            source "$STATE_FILE"
            COOKIE_VAR="\$cookie_${COOKIE_NAME}"
            return 0
        fi
        return 1
    }

    if [[ "$UPDATE_HTML_ONLY" == "true" ]]; then
        if _jsc_load_credentials; then
            info "Loaded existing credentials from ${STATE_FILE}"
            info "  Cookie name: ${COOKIE_NAME}  Token: ${COOKIE_VALUE:0:8}..."
        else
            err "No state file found at ${STATE_FILE}"
            echo "  Run without --update-html-only first to generate credentials." >&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 <user> <domain> <previous-template>"
        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='<!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

    # =====================================================
    # 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 <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;
}
'

    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 <user> <domain> ${TEMPLATE_NAME}"
        echo ""
        echo "  To apply to all domains for a user:"
        echo "    sudo $(basename "$0") js-challenge --apply-all <user>"
    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 <<EOF
Usage: sudo $(basename "$0") geoip [OPTIONS]

Sets up GeoIP2 enriched logging on HestiaCP / VestaCP / myVesta servers.
Creates geoip2 config in conf.d, builds custom nginx templates with enriched
access_log format, and optionally applies them to domains.

Options:
  --db-ip                Use DB-IP free databases (no signup required)
  --account-id ID        MaxMind account ID (not needed with --db-ip)
  --license-key KEY      MaxMind license key (not needed with --db-ip)
  --base-template NAME   Template to build on (default: default)
  --template-name NAME   Name for new template (default: default-geoip)
  --apply USER DOMAIN    Apply template to a specific domain after creation
  --apply-all USER       Apply template to all domains for a user
  --skip-packages        Skip package installation
  --dry-run              Show what would be done without making changes
  --remove               Remove managed configs, templates, and cron
  -h, --help             Show this help

Environment variables:
  MAXMIND_ACCOUNT_ID     MaxMind account ID (overridden by --account-id)
  MAXMIND_LICENSE_KEY    MaxMind license key (overridden by --license-key)

Database providers:
  MaxMind GeoLite2       Free, requires account signup at maxmind.com
  DB-IP Lite             Free, no signup, direct download (--db-ip)

Examples:
  sudo $(basename "$0") geoip --db-ip
  sudo $(basename "$0") geoip --db-ip --base-template default-botblock
  sudo $(basename "$0") geoip --db-ip --apply-all admin
  sudo $(basename "$0") geoip --account-id 123456 --license-key ABCDEF
  sudo $(basename "$0") geoip --dry-run --db-ip
  sudo $(basename "$0") geoip --remove
EOF
        exit 0
    }

    while [[ $# -gt 0 ]]; do
        case "$1" in
            --db-ip)           USE_DBIP=true; shift ;;
            --account-id)      ACCOUNT_ID="$2"; shift 2 ;;
            --license-key)     LICENSE_KEY="$2"; shift 2 ;;
            --base-template)   BASE_TEMPLATE="$2"; shift 2 ;;
            --template-name)   TEMPLATE_NAME="$2"; shift 2 ;;
            --apply)           APPLY_USER="$2"; APPLY_DOMAIN="$3"; shift 3 ;;
            --apply-all)       APPLY_USER="$2"; APPLY_ALL=true; shift 2 ;;
            --skip-packages)   SKIP_PACKAGES=true; shift ;;
            --dry-run)         DRY_RUN=true; shift ;;
            --remove)          REMOVE=true; shift ;;
            -h|--help)         usage_geoip ;;
            *)                 echo "Unknown option: $1"; usage_geoip ;;
        esac
    done

    require_root
    detect_panel
    require_nginx

    # =====================================================
    # REMOVE MODE
    # =====================================================
    if [[ "$REMOVE" == "true" ]]; then
        step "Removing GeoIP2 configuration"

        for file in "$NGINX_GEOIP_CONF" "$NGINX_LOG_CONF" "$APACHE_LOG_CONF" "$CRON_WEEKLY" "$CRON_MONTHLY"; do
            if [[ -f "$file" ]]; then
                if [[ "$DRY_RUN" == "true" ]]; then
                    echo "  Would remove: ${file}"
                else
                    rm -f "$file"
                    info "Removed: ${file}"
                fi
            else
                warn "Not found: ${file} (already removed?)"
            fi
        done

        local LOAD_MOD_CONF="/etc/nginx/modules-enabled/50-mod-http-geoip2.conf"
        if [[ -f "$LOAD_MOD_CONF" ]]; then
            if [[ "$DRY_RUN" == "true" ]]; then
                echo "  Would remove: ${LOAD_MOD_CONF}"
            else
                rm -f "$LOAD_MOD_CONF"
                info "Removed: ${LOAD_MOD_CONF}"
            fi
        fi

        for ext in tpl stpl; do
            local tpl_file="${PANEL_TPL_DIR}/${TEMPLATE_NAME}.${ext}"
            if [[ -f "$tpl_file" ]]; then
                if [[ "$DRY_RUN" == "true" ]]; then
                    echo "  Would remove: ${tpl_file}"
                else
                    rm -f "$tpl_file"
                    info "Removed: ${tpl_file}"
                fi
            fi
            tpl_file="${PANEL_TPL_DIR}/php-fpm/${TEMPLATE_NAME}.${ext}"
            if [[ -f "$tpl_file" ]]; then
                if [[ "$DRY_RUN" == "true" ]]; then
                    echo "  Would remove: ${tpl_file}"
                else
                    rm -f "$tpl_file"
                    info "Removed: ${tpl_file}"
                fi
            fi
        done

        for apache_dir in "/usr/local/hestia/data/templates/web/apache2/php-fpm" "/usr/local/hestia/data/templates/web/apache2"; do
            for ext in tpl stpl; do
                local tpl_file="${apache_dir}/${TEMPLATE_NAME}.${ext}"
                if [[ -f "$tpl_file" ]]; then
                    if [[ "$DRY_RUN" == "true" ]]; then
                        echo "  Would remove: ${tpl_file}"
                    else
                        rm -f "$tpl_file"
                        info "Removed: ${tpl_file}"
                    fi
                fi
            done
        done

        if [[ -f "$APACHE_LOG_CONF" ]]; then
            if [[ "$DRY_RUN" == "true" ]]; then
                echo "  Would run: a2disconf enriched-log-format"
            else
                a2disconf -q enriched-log-format 2>/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 <<GEOCONF || true
${MARKER_START}
# GeoIP2 variable mappings — generated by hestia-security.sh geoip

geoip2 ${GEOIP_DB_DIR}/GeoLite2-City.mmdb {
    auto_reload 60m;
    \$geoip2_country_code   country iso_code;
    \$geoip2_country_name   country names en;
    \$geoip2_city_name      city names en;
}

geoip2 ${GEOIP_DB_DIR}/GeoLite2-ASN.mmdb {
    auto_reload 60m;
    \$geoip2_asn            autonomous_system_number;
    \$geoip2_asn_org        autonomous_system_organization;
}
${MARKER_END}
GEOCONF

    if [[ "$DRY_RUN" == "true" ]]; then
        echo "  Would create: ${NGINX_GEOIP_CONF}"
    else
        backup_file "$NGINX_GEOIP_CONF"
        echo "$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 <<LOGCONF || true
${MARKER_START}
# Enriched log format with GeoIP2 data — generated by hestia-security.sh geoip

log_format enriched '\$remote_addr - \$remote_user [\$time_local] '
                    '"\$request" \$status \$body_bytes_sent '
                    '"\$http_referer" "\$http_user_agent" '
                    '\$geoip2_country_code "\$geoip2_asn_org"';
${MARKER_END}
LOGCONF

    if [[ "$DRY_RUN" == "true" ]]; then
        echo "  Would create: ${NGINX_LOG_CONF}"
    else
        backup_file "$NGINX_LOG_CONF"
        echo "$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 <<ALOGCONF || true
${MARKER_START}
# Enriched log format with GeoIP2 data (passed from nginx) — generated by hestia-security.sh geoip
LogFormat "%h %l %u %t \"%r\" %>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 <<CRONEOF || true
#!/bin/sh
# Monthly DB-IP database update — generated by hestia-security.sh geoip
MONTH=\$(date +%Y-%m)
cd ${GEOIP_DB_DIR} && \\
  curl -fsSL "https://download.db-ip.com/free/dbip-country-lite-\${MONTH}.mmdb.gz" | gunzip > 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 <<CRONEOF || true
#!/bin/sh
# Weekly GeoIP2 database update — generated by hestia-security.sh geoip
/usr/bin/geoipupdate -s 2>&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 <user> <domain> ${TEMPLATE_NAME}"
        echo ""
        echo "  To apply to all domains for a user:"
        echo "    sudo $(basename "$0") geoip --apply-all <user>"
    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 <<EOF
Usage: sudo $(basename "$0") crowdsec [OPTIONS]

Installs and configures CrowdSec engine, nginx lua bouncer, and HestiaCP log
acquisition. Provides automated bot/attack detection and mitigation.

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 YOUR_KEY
  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 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
