#!/bin/bash
################################################################################
# Script Name: add-fail2ban-nginx-hardening.sh
# Version: 1.0
# Description: Adds custom Fail2ban jails to block vulnerability scanners,
#              script probes, and path enumeration attacks on nginx servers.
#              Installs filters + jails and reloads Fail2ban.
#
# Author:  Phil Connor
# Contact: contact@mylinux.work
# Website: https://mylinux.work
# License: MIT
#
# Usage:
#   sudo ./add-fail2ban-nginx-hardening.sh
#   sudo ./add-fail2ban-nginx-hardening.sh --logpath /var/log/nginx/access.log
#   sudo ./add-fail2ban-nginx-hardening.sh --bantime 604800
#   sudo ./add-fail2ban-nginx-hardening.sh --dry-run
#
################################################################################

set -euo pipefail

# ============================================================================
# DEFAULTS
# ============================================================================

readonly VERSION="1.0"
readonly SCRIPT_NAME="${0##*/}"

ACCESS_LOGPATH="auto"
ERROR_LOGPATH="auto"
BANTIME="86400"
DRY_RUN=false
SKIP_JAILS=""
ALLOW_EXTENSIONS=""
ALLOW_PATHS=""

# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'

# ============================================================================
# HELPER FUNCTIONS
# ============================================================================

log_info()  { echo -e "${GREEN}[INFO]${NC} $*"; }
log_warn()  { echo -e "${YELLOW}[WARN]${NC} $*"; }
log_error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
log_step()  { echo -e "${CYAN}[STEP]${NC} $*"; }

show_usage() {
    cat <<EOF
Usage: sudo $SCRIPT_NAME [OPTIONS]

Adds nginx hardening jails to an existing Fail2ban installation.
Blocks vulnerability scanners, script probes, and path enumeration.

OPTIONS:
    --logpath PATH      Nginx access log path (default: auto-detect)
    --error-logpath P   Nginx error log path (default: auto-detect)
    --bantime SECS      Ban duration in seconds (default: 86400 / 24 hours)
    --skip JAILS        Comma-separated jails to skip
                        (noscript, pathscan, 4xx-flood)
    --allow-ext LIST    Comma-separated extensions to whitelist in noscript
                        (e.g., php,py for PHP/Python sites)
    --allow-paths LIST  Comma-separated paths to whitelist in pathscan
                        (e.g., wp-admin,phpmyadmin)
    --dry-run           Show what would be done without making changes
    -h, --help          Show this help message

JAILS INSTALLED:
    nginx-noscript      Blocks requests for .php, .asp, .jsp, .cgi, .exe
                        on servers that don't serve dynamic content.
                        *** SKIP this on PHP/ASP/Python sites or use
                        --allow-ext to whitelist your extensions ***
    nginx-pathscan      Blocks requests for sensitive paths like .env,
                        .git/, wp-admin, phpmyadmin, .aws/credentials.
                        *** Use --allow-paths to whitelist paths you
                        actually use (e.g., wp-admin on WordPress) ***
    nginx-4xx-flood     Bans IPs generating excessive 404/403 errors
                        (automated path enumeration / directory brute force).
                        Safe for all server types.

EXAMPLES:
    # Static site — install all three jails
    sudo $SCRIPT_NAME

    # WordPress / PHP site — skip noscript, whitelist wp-admin
    sudo $SCRIPT_NAME --skip noscript --allow-paths wp-admin,wp-login,xmlrpc

    # PHP site with phpMyAdmin
    sudo $SCRIPT_NAME --skip noscript --allow-paths phpmyadmin,pma

    # Python/Django site
    sudo $SCRIPT_NAME --allow-ext py --allow-paths admin

    # Laravel site — only 4xx-flood and pathscan
    sudo $SCRIPT_NAME --skip noscript --allow-paths api

    # Ban for 7 days
    sudo $SCRIPT_NAME --bantime 604800

    # Preview without changes
    sudo $SCRIPT_NAME --dry-run

EOF
    exit 0
}

parse_args() {
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --logpath)       ACCESS_LOGPATH="$2"; shift 2 ;;
            --error-logpath) ERROR_LOGPATH="$2"; shift 2 ;;
            --bantime)       BANTIME="$2"; shift 2 ;;
            --skip)          SKIP_JAILS="$2"; shift 2 ;;
            --allow-ext)     ALLOW_EXTENSIONS="$2"; shift 2 ;;
            --allow-paths)   ALLOW_PATHS="$2"; shift 2 ;;
            --dry-run)       DRY_RUN=true; shift ;;
            -h|--help)       show_usage ;;
            *)               log_error "Unknown option: $1"; show_usage ;;
        esac
    done
}

check_root() {
    if [[ $EUID -ne 0 ]]; then
        log_error "This script must be run as root (sudo)"
        exit 1
    fi
}

# ============================================================================
# CHECKS
# ============================================================================

check_fail2ban() {
    if ! command -v fail2ban-client &>/dev/null; then
        log_error "Fail2ban is not installed"
        log_error "Install it first: https://mylinux.work/guides/fail2ban-setup/"
        exit 1
    fi

    if ! systemctl is-active --quiet fail2ban; then
        log_error "Fail2ban is not running"
        exit 1
    fi

    log_info "Fail2ban is installed and running"
}

detect_logpaths() {
    # Access log
    if [[ "$ACCESS_LOGPATH" == "auto" ]]; then
        if [[ -f /var/log/nginx/access.log ]]; then
            ACCESS_LOGPATH="/var/log/nginx/access.log"
        else
            log_error "Could not find nginx access log. Use --logpath to specify."
            exit 1
        fi
    elif [[ ! -f "$ACCESS_LOGPATH" ]]; then
        log_error "Access log not found: $ACCESS_LOGPATH"
        exit 1
    fi
    log_info "Access log: $ACCESS_LOGPATH"

    # Error log
    if [[ "$ERROR_LOGPATH" == "auto" ]]; then
        if [[ -f /var/log/nginx/error.log ]]; then
            ERROR_LOGPATH="/var/log/nginx/error.log"
        else
            ERROR_LOGPATH="$ACCESS_LOGPATH"
            log_warn "Error log not found — using access log for all jails"
        fi
    fi
    log_info "Error log: $ERROR_LOGPATH"
}

should_skip() {
    local jail="$1"
    [[ ",$SKIP_JAILS," == *",$jail,"* ]]
}

# Build extension regex excluding allowed extensions
build_extension_regex() {
    local all_exts="php|asp|aspx|jsp|cgi|exe|pl"
    if [[ -n "$ALLOW_EXTENSIONS" ]]; then
        local result=""
        IFS='|' read -ra EXT_ARRAY <<< "${all_exts}"
        for ext in "${EXT_ARRAY[@]}"; do
            if [[ ",$ALLOW_EXTENSIONS," != *",$ext,"* ]]; then
                [[ -n "$result" ]] && result="${result}|"
                result="${result}${ext}"
            fi
        done
        if [[ -z "$result" ]]; then
            log_warn "All extensions whitelisted — skipping nginx-noscript"
            return 1
        fi
        echo "$result"
    else
        echo "$all_exts"
    fi
    return 0
}

# Build path ignore regex from allowed paths
build_path_ignoreregex() {
    if [[ -z "$ALLOW_PATHS" ]]; then
        echo ""
        return
    fi
    local ignore_parts=""
    IFS=',' read -ra PATH_ARRAY <<< "$ALLOW_PATHS"
    for p in "${PATH_ARRAY[@]}"; do
        p=$(echo "$p" | xargs | sed 's|^/||')
        [[ -n "$ignore_parts" ]] && ignore_parts="${ignore_parts}|"
        ignore_parts="${ignore_parts}/${p}"
    done
    echo "ignoreregex = ^<HOST> \\S+ \\S+ \\[.*\\] \"\\S+ [^\"]*($ignore_parts)[^\"]*HTTP"
}

# ============================================================================
# HELPER — write file with backup
# ============================================================================

write_config() {
    local file="$1"
    local label="$2"

    if $DRY_RUN; then
        log_info "[DRY RUN] Would create $file"
        echo ""
        cat
        echo ""
        return
    fi

    if [[ -f "$file" ]]; then
        log_warn "$label already exists — backing up to ${file}.bak"
        cp "$file" "${file}.bak"
    fi

    cat > "$file"
    log_info "$label installed: $file"
}

# ============================================================================
# JAIL 1: nginx-noscript
# ============================================================================

install_noscript() {
    if should_skip "noscript"; then
        log_info "Skipping nginx-noscript (--skip)"
        return
    fi

    local ext_regex
    ext_regex=$(build_extension_regex) || return 0

    log_step "Installing nginx-noscript filter and jail..."
    if [[ -n "$ALLOW_EXTENSIONS" ]]; then
        log_info "Whitelisted extensions: $ALLOW_EXTENSIONS"
    fi
    log_info "Blocking extensions: $ext_regex"

    # Filter
    generate_noscript_filter "$ext_regex" | write_config \
        /etc/fail2ban/filter.d/nginx-noscript.conf \
        "nginx-noscript filter"

    # Jail
    generate_noscript_jail | write_config \
        /etc/fail2ban/jail.d/nginx-noscript.conf \
        "nginx-noscript jail"
}

generate_noscript_filter() {
    local ext_regex="$1"
    cat <<EOF
# Fail2ban filter — nginx-noscript
# Blocks requests for dynamic script extensions that should never exist
# on a static site or any server not running those technologies.
# https://mylinux.work
#
# Blocked extensions: $ext_regex

[Definition]

# Match requests for script extensions in nginx access logs
failregex = ^<HOST> \S+ \S+ \[.*\] "(GET|POST|HEAD|PUT|DELETE|OPTIONS) [^"]*\.($ext_regex)(\?[^\"]*)? HTTP[^"]*" (400|403|404|444) 

ignoreregex =

# Author: Phil Connor — https://mylinux.work
EOF
}

generate_noscript_jail() {
    cat <<EOF
# Fail2ban jail — nginx-noscript
# Ban IPs requesting dynamic script files
# https://mylinux.work

[nginx-noscript]
enabled  = true
port     = http,https
filter   = nginx-noscript
logpath  = $ACCESS_LOGPATH
maxretry = 2
bantime  = $BANTIME
findtime = 3600

# Author: Phil Connor — https://mylinux.work
EOF
}

# ============================================================================
# JAIL 2: nginx-pathscan
# ============================================================================

install_pathscan() {
    if should_skip "pathscan"; then
        log_info "Skipping nginx-pathscan (--skip)"
        return
    fi

    log_step "Installing nginx-pathscan filter and jail..."
    if [[ -n "$ALLOW_PATHS" ]]; then
        log_info "Whitelisted paths: $ALLOW_PATHS"
    fi

    # Filter
    local ignore_line
    ignore_line=$(build_path_ignoreregex)
    generate_pathscan_filter "$ignore_line" | write_config \
        /etc/fail2ban/filter.d/nginx-pathscan.conf \
        "nginx-pathscan filter"

    # Jail
    generate_pathscan_jail | write_config \
        /etc/fail2ban/jail.d/nginx-pathscan.conf \
        "nginx-pathscan jail"
}

generate_pathscan_filter() {
    local ignore_line="$1"
    [[ -z "$ignore_line" ]] && ignore_line="ignoreregex ="

    cat <<EOF
# Fail2ban filter — nginx-pathscan
# Blocks requests for sensitive paths commonly targeted by vulnerability
# scanners, script kiddies, and automated exploit tools.
# https://mylinux.work

[Definition]

# Match requests for known sensitive/exploit paths
# Covers: environment files, version control, CMS admin panels,
#         database tools, config files, cloud credentials
failregex = ^<HOST> \S+ \S+ \[.*\] "(GET|POST|HEAD) [^"]*(/\.env|/\.git|/\.svn|/\.hg|/\.htaccess|/\.htpasswd|/\.aws|/\.docker|/\.ssh|/\.kube|/\.config|/wp-admin|/wp-login|/wp-config|/wp-content/uploads|/wp-includes|/xmlrpc\.php|/administrator|/admin/config|/phpmyadmin|/pma|/myadmin|/dbadmin|/mysql|/phpinfo|/info\.php|/server-status|/server-info|/cgi-bin|/shell|/cmd|/command|/console|/config\.json|/config\.yml|/config\.yaml|/config\.xml|/database\.yml|/backup|/dump|/db\.sql|/\.sql|/api/v1/debug|/debug|/trace|/actuator|/swagger|/graphql|/solr|/elasticsearch|/_cat|/_cluster)[^"]*HTTP[^"]*" (400|403|404|444) 

$ignore_line

# Author: Phil Connor — https://mylinux.work
EOF
}

generate_pathscan_jail() {
    cat <<EOF
# Fail2ban jail — nginx-pathscan
# Ban IPs probing for sensitive paths
# https://mylinux.work

[nginx-pathscan]
enabled  = true
port     = http,https
filter   = nginx-pathscan
logpath  = $ACCESS_LOGPATH
maxretry = 1
bantime  = $BANTIME
findtime = 3600

# Author: Phil Connor — https://mylinux.work
EOF
}

# ============================================================================
# JAIL 3: nginx-4xx-flood
# ============================================================================

install_4xx_flood() {
    if should_skip "4xx-flood"; then
        log_info "Skipping nginx-4xx-flood (--skip)"
        return
    fi

    log_step "Installing nginx-4xx-flood filter and jail..."

    # Filter
    generate_4xx_filter | write_config \
        /etc/fail2ban/filter.d/nginx-4xx-flood.conf \
        "nginx-4xx-flood filter"

    # Jail
    generate_4xx_jail | write_config \
        /etc/fail2ban/jail.d/nginx-4xx-flood.conf \
        "nginx-4xx-flood jail"
}

generate_4xx_filter() {
    cat <<'EOF'
# Fail2ban filter — nginx-4xx-flood
# Bans IPs generating excessive 4xx errors in a short period.
# Catches automated directory brute force and path enumeration.
# https://mylinux.work

[Definition]

# Match 400, 401, 403, 404, 405, 444 responses
failregex = ^<HOST> \S+ \S+ \[.*\] "\S+ \S+ \S+" (400|401|403|404|405|444) 

ignoreregex =

# Author: Phil Connor — https://mylinux.work
EOF
}

generate_4xx_jail() {
    cat <<EOF
# Fail2ban jail — nginx-4xx-flood
# Ban IPs with excessive 4xx errors (directory brute force)
# https://mylinux.work

[nginx-4xx-flood]
enabled  = true
port     = http,https
filter   = nginx-4xx-flood
logpath  = $ACCESS_LOGPATH
maxretry = 20
bantime  = $BANTIME
findtime = 300

# Author: Phil Connor — https://mylinux.work
EOF
}

# ============================================================================
# RELOAD AND VERIFY
# ============================================================================

reload_fail2ban() {
    log_step "Reloading Fail2ban..."

    if $DRY_RUN; then
        log_info "[DRY RUN] Would reload fail2ban"
        return
    fi

    fail2ban-client reload
    sleep 2

    if systemctl is-active --quiet fail2ban; then
        log_info "Fail2ban reloaded successfully"
    else
        log_error "Fail2ban failed to restart — check: journalctl -u fail2ban"
        exit 1
    fi
}

verify_jails() {
    log_step "Verifying jails..."

    if $DRY_RUN; then
        log_info "[DRY RUN] Would verify jail status"
        return
    fi

    local all_ok=true
    echo ""
    for jail in noscript pathscan 4xx-flood; do
        if should_skip "$jail"; then
            continue
        fi
        local full_jail="nginx-${jail}"
        if fail2ban-client status "$full_jail" &>/dev/null; then
            log_info "  ✓ $full_jail is active"
        else
            log_error "  ✗ $full_jail failed to start"
            all_ok=false
        fi
    done
    echo ""

    if ! $all_ok; then
        log_error "Some jails failed — debug with:"
        log_error "  fail2ban-client status"
        log_error "  fail2ban-regex $ACCESS_LOGPATH /etc/fail2ban/filter.d/<filter>.conf"
        exit 1
    fi
}

# ============================================================================
# MAIN
# ============================================================================

main() {
    parse_args "$@"

    echo ""
    echo "============================================"
    echo "  Fail2ban Nginx Hardening v${VERSION}"
    echo "  https://mylinux.work"
    echo "============================================"
    echo ""

    check_root
    check_fail2ban
    detect_logpaths

    install_noscript
    install_pathscan
    install_4xx_flood

    reload_fail2ban
    verify_jails

    local installed=0
    should_skip "noscript"  || installed=$((installed + 1))
    should_skip "pathscan"  || installed=$((installed + 1))
    should_skip "4xx-flood" || installed=$((installed + 1))

    echo "============================================"
    echo "  Setup Complete — ${installed} Jail(s) Installed"
    echo "============================================"
    echo ""
    echo "  Jails:"
    should_skip "noscript"  || echo "    nginx-noscript   Ban after 2 script requests (.php, .asp, etc.)"
    should_skip "pathscan"  || echo "    nginx-pathscan   Ban on first sensitive path probe (.env, .git, wp-admin)"
    should_skip "4xx-flood" || echo "    nginx-4xx-flood  Ban after 20 errors in 5 minutes"
    echo ""
    echo "  Log:       $ACCESS_LOGPATH"
    echo "  Ban time:  ${BANTIME}s ($(( BANTIME / 3600 ))h)"
    echo ""
    echo "  Useful commands:"
    echo "    fail2ban-client status nginx-noscript"
    echo "    fail2ban-client status nginx-pathscan"
    echo "    fail2ban-client status nginx-4xx-flood"
    echo "    fail2ban-client set <jail> unbanip <IP>"
    echo "    fail2ban-regex $ACCESS_LOGPATH /etc/fail2ban/filter.d/nginx-pathscan.conf"
    echo ""
}

main "$@"
