a1a17e81a1
Includes updated JS challenge scripts with Claude-User whitelist, same-site referer bypass, Blackbox-Exporter allowed bot, and all new exporters, cheat sheets, and automation scripts.
2514 lines
89 KiB
Bash
Executable File
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
|