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

2514 lines
89 KiB
Bash
Executable File

#!/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