#!/bin/bash ################################################################################ # Script Name: setup-web-server.sh # Version: 1.1 # Description: Production setup for a dedicated nginx web server hosting a # Hugo static site with GeoIP2 enriched logging. Subcommand- # based: base hardening, nginx install, Hugo, # TLS, security toolkit, and status dashboard. # # Author: Phil Connor # Contact: contact@mylinux.work # Website: https://mylinux.work # License: MIT # # Subcommands: # base — Server hardening (firewall, fail2ban, sysctl, unattended-upgrades) # nginx — Install nginx from official repo + production config # hugo — Install Hugo extended + site directory + deploy script # tls — Let's Encrypt certificates # security — Download and run nginx-security.sh toolkit # status — Dashboard showing all component status # all — Run base → nginx → hugo → tls → security # # Usage: # sudo ./setup-web-server.sh base [OPTIONS] # sudo ./setup-web-server.sh nginx --domain mylinux.work # sudo ./setup-web-server.sh hugo --domain mylinux.work # sudo ./setup-web-server.sh tls --domain mylinux.work --email admin@example.com # sudo ./setup-web-server.sh security # sudo ./setup-web-server.sh status # sudo ./setup-web-server.sh all --domain mylinux.work # ################################################################################ set -euo pipefail # ============================================================================= # SHARED HELPERS # ============================================================================= # --- Colors (TTY-aware) --- if [[ -t 1 ]]; then RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m' CYAN='\033[0;36m' BOLD='\033[1m' NC='\033[0m' else RED="" GREEN="" YELLOW="" CYAN="" BOLD="" NC="" fi # --- Logging (prefixed with # for Prometheus comment compatibility) --- info() { echo -e "# ${GREEN}[OK]${NC} $*"; } warn() { echo -e "# ${YELLOW}[WARN]${NC} $*"; } die() { echo -e "# ${RED}[FATAL]${NC} $*" >&2; exit 1; } step() { echo -e "# ${CYAN}[STEP]${NC} $*"; } banner() { echo echo -e "# ${BOLD}════════════════════════════════════════${NC}" echo -e "# ${BOLD} $*${NC}" echo -e "# ${BOLD}════════════════════════════════════════${NC}" echo } # --- Root check --- require_root() { [[ $EUID -eq 0 ]] || die "This script must be run as root (or via sudo)" } # --- OS detection --- OS="" OSVER="" PKG_MGR="" detect_os() { if [[ -f /etc/os-release ]]; then OS=$(grep -oP '(?<=^ID=).+' /etc/os-release | tr -d '"' | tr '[:upper:]' '[:lower:]') OSVER=$(grep -oP '(?<=^VERSION_ID=).+' /etc/os-release | tr -d '"' | cut -d. -f1) fi case "$OS" in ubuntu|debian) PKG_MGR="apt -y" ;; rocky|almalinux|centos|rhel|ol) PKG_MGR=$(command -v dnf >/dev/null 2>&1 && echo "dnf -y" || echo "yum -y") ;; *) die "Unsupported OS: ${OS:-unknown}. Requires Ubuntu/Debian or RHEL/Rocky." ;; esac info "Detected ${OS} ${OSVER}, using ${PKG_MGR}" } # --- Architecture --- detect_arch() { local arch arch=$(uname -m) case "$arch" in x86_64) echo "amd64" ;; aarch64) echo "arm64" ;; *) die "Unsupported architecture: $arch" ;; esac } # --- Backup file with timestamp --- backup_file() { local file="$1" if [[ -f "$file" ]]; then local ts ts=$(date +%Y%m%d-%H%M%S) cp "$file" "${file}.bak-${ts}" info "Backed up ${file}" fi } # --- nginx test and reload --- nginx_test_and_reload() { if nginx -t 2>&1; then if systemctl is-active --quiet nginx 2>/dev/null; then systemctl reload nginx info "nginx config valid — reloaded" else systemctl start nginx info "nginx config valid — started" fi else die "nginx config test failed — not reloading" fi } # --- Generate password --- generate_password() { openssl rand -base64 24 | tr -d '/+=' | head -c 24 } # ============================================================================= # DEFAULTS # ============================================================================= DOMAIN="mylinux.work" HOSTNAME_SET="web01" TIMEZONE="America/Chicago" ADMIN_EMAIL="contact@mylinux.work" ADMIN_NAME="chiefgeek" ADMIN_PASS="" DB_PASS="" CERTBOT_EMAIL="contact@mylinux.work" HUGO_VERSION="latest" DRY_RUN=false SKIP_HUGO=false SKIP_TLS=false # ============================================================================= # ARGUMENT PARSING # ============================================================================= SUBCOMMAND="" parse_args() { if [[ $# -lt 1 ]]; then show_help exit 0 fi # Handle help before subcommand assignment case "$1" in --help|-h|help) show_help; exit 0 ;; --*) die "Missing subcommand. Usage: $0 [OPTIONS]\n# Run '$0 --help' for available subcommands." ;; esac SUBCOMMAND="$1" shift while [[ $# -gt 0 ]]; do case "$1" in --domain) DOMAIN="$2"; shift 2 ;; --hostname) HOSTNAME_SET="$2"; shift 2 ;; --timezone) TIMEZONE="$2"; shift 2 ;; --admin-email) ADMIN_EMAIL="$2"; shift 2 ;; --admin-name) ADMIN_NAME="$2"; shift 2 ;; --admin-pass) ADMIN_PASS="$2"; shift 2 ;; --db-pass) DB_PASS="$2"; shift 2 ;; --email) CERTBOT_EMAIL="$2"; shift 2 ;; --hugo-version) HUGO_VERSION="$2"; shift 2 ;; --skip-hugo) SKIP_HUGO=true; shift ;; --skip-tls) SKIP_TLS=true; shift ;; --dry-run) DRY_RUN=true; shift ;; --help|-h) show_help; exit 0 ;; *) die "Unknown option: $1" ;; esac done } show_help() { cat <<'EOF' setup-web-server.sh — Production web server setup USAGE: sudo ./setup-web-server.sh SUBCOMMAND [OPTIONS] SUBCOMMANDS: base Server hardening (firewall, fail2ban, sysctl, updates) nginx Install nginx from official repo + production config hugo Install Hugo extended + site directory + deploy script tls Let's Encrypt certificates security Download and run nginx-security.sh toolkit status Show all component status all Run everything: base → nginx → hugo → tls → security OPTIONS: --domain DOMAIN Primary domain (default: mylinux.work) --hostname NAME Server hostname (default: web01) --timezone ZONE Timezone (default: America/Chicago) --admin-email EMAIL Admin email (default: contact@mylinux.work) --admin-name NAME Admin username (default: chiefgeek) --admin-pass PASS Admin password (generated if not set) --db-pass PASS MySQL password (generated if not set) --email EMAIL Certbot email (default: contact@mylinux.work) --hugo-version VERSION Hugo version (default: latest) --skip-hugo Skip Hugo install in 'all' (for rsync-based deploys) --skip-tls Skip TLS setup in 'all' (DNS not ready yet) --dry-run Show what would be done --help Show this help EXAMPLES: sudo ./setup-web-server.sh base --hostname web01 --timezone America/Chicago sudo ./setup-web-server.sh nginx --domain mylinux.work sudo ./setup-web-server.sh all --domain mylinux.work sudo ./setup-web-server.sh status EOF } # ============================================================================= # SUBCOMMAND: base # ============================================================================= cmd_base() { banner "Base Server Setup" # --- Hostname --- step "Setting hostname to ${HOSTNAME_SET}" if [[ "$DRY_RUN" == "true" ]]; then info "[DRY RUN] Would set hostname to ${HOSTNAME_SET}" else hostnamectl set-hostname "$HOSTNAME_SET" info "Hostname set to ${HOSTNAME_SET}" fi # --- Timezone --- step "Setting timezone to ${TIMEZONE}" if [[ "$DRY_RUN" == "true" ]]; then info "[DRY RUN] Would set timezone to ${TIMEZONE}" else timedatectl set-timezone "$TIMEZONE" info "Timezone set to ${TIMEZONE}" fi # --- Update packages --- step "Updating packages" if [[ "$DRY_RUN" == "true" ]]; then info "[DRY RUN] Would update packages" else case "$OS" in ubuntu|debian) apt update && apt upgrade -y ;; *) $PKG_MGR update ;; esac info "Packages updated" fi # --- Base tools --- step "Installing base tools" if [[ "$DRY_RUN" == "true" ]]; then info "[DRY RUN] Would install base packages" else case "$OS" in ubuntu|debian) apt install -y curl wget git htop tmux unzip jq tree ncdu \ fail2ban ufw net-tools dnsutils software-properties-common \ gnupg2 ca-certificates lsb-release openssl ;; *) $PKG_MGR install curl wget git htop tmux unzip jq tree ncdu \ fail2ban firewalld net-tools bind-utils openssl ;; esac info "Base tools installed" fi # --- Firewall --- step "Configuring firewall" if [[ "$DRY_RUN" == "true" ]]; then info "[DRY RUN] Would configure firewall (allow 22, 80, 443)" else case "$OS" in ubuntu|debian) ufw default deny incoming 2>/dev/null || true ufw default allow outgoing 2>/dev/null || true ufw allow 22/tcp comment 'SSH' 2>/dev/null || true ufw allow 80/tcp comment 'HTTP' 2>/dev/null || true ufw allow 443/tcp comment 'HTTPS' 2>/dev/null || true ufw --force enable ;; *) systemctl enable --now firewalld firewall-cmd --permanent --add-service=ssh firewall-cmd --permanent --add-service=http firewall-cmd --permanent --add-service=https firewall-cmd --reload ;; esac info "Firewall configured" fi # --- fail2ban --- step "Configuring fail2ban" if [[ "$DRY_RUN" == "true" ]]; then info "[DRY RUN] Would configure fail2ban for SSH" else local logpath="/var/log/auth.log" [[ "$OS" =~ ^(rocky|almalinux|centos|rhel|ol)$ ]] && logpath="/var/log/secure" cat > /etc/fail2ban/jail.local </dev/null || true ;; *) $PKG_MGR install dnf-automatic sed -i 's/apply_updates = no/apply_updates = yes/' /etc/dnf/automatic.conf 2>/dev/null || true systemctl enable --now dnf-automatic.timer ;; esac info "Unattended upgrades enabled" fi # --- Sysctl tuning --- step "Applying sysctl tuning for web serving" if [[ "$DRY_RUN" == "true" ]]; then info "[DRY RUN] Would write /etc/sysctl.d/99-web-tuning.conf" else cat > /etc/sysctl.d/99-web-tuning.conf <<'SYSEOF' net.core.somaxconn = 65535 net.ipv4.tcp_fastopen = 3 fs.file-max = 2097152 net.ipv4.tcp_keepalive_time = 600 net.ipv4.tcp_keepalive_intvl = 30 net.ipv4.tcp_keepalive_probes = 5 net.core.rmem_max = 16777216 net.core.wmem_max = 16777216 net.ipv4.tcp_rmem = 4096 87380 16777216 net.ipv4.tcp_wmem = 4096 65536 16777216 net.ipv4.tcp_tw_reuse = 1 net.ipv4.tcp_syncookies = 1 net.ipv4.tcp_max_syn_backlog = 65535 SYSEOF sysctl --system >/dev/null 2>&1 info "Sysctl tuning applied" fi # --- File limits --- step "Setting file descriptor limits" if [[ "$DRY_RUN" == "true" ]]; then info "[DRY RUN] Would set file limits" else if ! grep -q "# web-server-setup" /etc/security/limits.conf 2>/dev/null; then cat >> /etc/security/limits.conf <<'LIMEOF' # web-server-setup * soft nofile 65536 * hard nofile 65536 root soft nofile 65536 root hard nofile 65536 LIMEOF fi info "File limits configured" fi info "Base server setup complete" } # ============================================================================= # SUBCOMMAND: nginx # ============================================================================= cmd_nginx() { banner "Nginx Installation" # --- Install nginx from OS package manager --- step "Installing nginx" if [[ "$DRY_RUN" == "true" ]]; then info "[DRY RUN] Would install nginx" else $PKG_MGR install nginx info "nginx installed: $(nginx -v 2>&1)" fi # --- Production nginx.conf --- step "Writing production nginx.conf" if [[ "$DRY_RUN" == "true" ]]; then info "[DRY RUN] Would write /etc/nginx/nginx.conf" else backup_file /etc/nginx/nginx.conf local cores cores=$(nproc) local connections=$((cores * 4096)) [[ $connections -gt 65536 ]] && connections=65536 cat > /etc/nginx/nginx.conf </dev/null || useradd -r -s /usr/sbin/nologin www-data info "nginx.conf written (${cores} cores, ${connections} connections)" fi # --- Site directories --- step "Creating nginx config structure" if [[ "$DRY_RUN" == "true" ]]; then info "[DRY RUN] Would create conf.d directory and site configs" else mkdir -p /etc/nginx/conf.d # Remove default configs that conflict rm -f /etc/nginx/conf.d/default.conf rm -f /etc/nginx/sites-enabled/default 2>/dev/null || true fi # --- Hugo site server block --- step "Creating nginx config for ${DOMAIN}" if [[ "$DRY_RUN" == "true" ]]; then info "[DRY RUN] Would create /etc/nginx/conf.d/${DOMAIN}.conf" else mkdir -p "/var/www/${DOMAIN}/public" cat > "/etc/nginx/conf.d/${DOMAIN}.conf" </dev/null; then local current current=$(hugo version 2>/dev/null | grep -oP 'v\K[0-9.]+' | head -1) if [[ "$current" == "$version" ]]; then info "Hugo ${version} already installed" else info "Upgrading Hugo from ${current} to ${version}" fi fi local url="https://github.com/gohugoio/hugo/releases/download/v${version}/hugo_extended_${version}_linux-${arch}.tar.gz" local tmp="/tmp/hugo-install-$$" mkdir -p "$tmp" wget -qO "${tmp}/hugo.tar.gz" "$url" || die "Failed to download Hugo ${version}" tar -xzf "${tmp}/hugo.tar.gz" -C "$tmp" mv "${tmp}/hugo" /usr/local/bin/hugo chmod +x /usr/local/bin/hugo rm -rf "$tmp" info "Hugo installed: $(hugo version 2>&1 | head -1)" fi # --- Site directory --- step "Creating site directory /var/www/${DOMAIN}" if [[ "$DRY_RUN" == "true" ]]; then info "[DRY RUN] Would create /var/www/${DOMAIN} and deploy user" else mkdir -p "/var/www/${DOMAIN}/public" mkdir -p "/var/www/${DOMAIN}/source" # Create deploy user if ! id www-deploy &>/dev/null; then useradd -r -s /bin/bash -m -d /home/www-deploy www-deploy info "Created www-deploy user" fi # Ensure www-data group exists getent group www-data &>/dev/null || groupadd -r www-data usermod -aG www-data www-deploy 2>/dev/null || true chown -R www-deploy:www-data "/var/www/${DOMAIN}" chmod -R 775 "/var/www/${DOMAIN}" info "Site directory ready" fi # --- Deploy script --- step "Creating deploy script" if [[ "$DRY_RUN" == "true" ]]; then info "[DRY RUN] Would create /usr/local/bin/deploy-site.sh" else cat > /usr/local/bin/deploy-site.sh <&1 || warn "certbot for ${DOMAIN} returned non-zero (may already exist)" step "Verifying auto-renewal" certbot renew --dry-run 2>&1 || warn "Renewal dry-run had issues" info "TLS certificates configured" fi } # ============================================================================= # SUBCOMMAND: security # ============================================================================= cmd_security() { banner "Security Toolkit" local script_path="/usr/local/bin/nginx-security.sh" step "Checking for nginx-security.sh" if [[ "$DRY_RUN" == "true" ]]; then info "[DRY RUN] Would download and run nginx-security.sh" return fi if [[ ! -f "$script_path" ]]; then step "Downloading nginx-security.sh" wget -qO "$script_path" "https://mylinux.work/downloads/nginx-security.sh" || \ die "Failed to download nginx-security.sh" chmod +x "$script_path" info "Downloaded nginx-security.sh" else info "nginx-security.sh already present" fi step "Running bot-block" "$script_path" bot-block || warn "bot-block returned non-zero" step "Running js-challenge" "$script_path" js-challenge --db-ip || warn "js-challenge returned non-zero" step "Running crowdsec" "$script_path" crowdsec || warn "crowdsec returned non-zero" echo info "Security toolkit applied" info "Optional: ${script_path} block-head" info "Status: ${script_path} status" } # ============================================================================= # SUBCOMMAND: status # ============================================================================= cmd_status() { banner "Server Status Dashboard" echo -e "# ${BOLD}System${NC}" echo -e "# Hostname: $(hostname)" echo -e "# OS: $(grep PRETTY_NAME /etc/os-release 2>/dev/null | cut -d'"' -f2)" echo -e "# Kernel: $(uname -r)" echo -e "# Uptime: $(uptime -p 2>/dev/null || uptime)" echo -e "# CPU: $(nproc) cores" echo -e "# RAM: $(free -h | awk '/Mem:/{print $2}') total, $(free -h | awk '/Mem:/{print $3}') used" echo -e "# Disk: $(df -h / | awk 'NR==2{print $3"/"$2" ("$5" used)"}')" echo echo -e "# ${BOLD}Nginx${NC}" if systemctl is-active --quiet nginx 2>/dev/null; then echo -e "# Status: ${GREEN}running${NC}" echo -e "# Version: $(nginx -v 2>&1 | cut -d/ -f2)" if nginx -t 2>/dev/null; then echo -e "# Config: ${GREEN}valid${NC}" else echo -e "# Config: ${RED}invalid${NC}" fi else echo -e "# Status: ${RED}stopped${NC}" fi echo echo -e "# ${BOLD}Hugo Site${NC}" if [[ -d "/var/www/${DOMAIN}/public" ]]; then local file_count file_count=$(find "/var/www/${DOMAIN}/public" -type f 2>/dev/null | wc -l) echo -e "# Directory: /var/www/${DOMAIN}/public" echo -e "# Files: ${file_count}" if [[ -d "/var/www/${DOMAIN}/source/.git" ]]; then local last_commit last_commit=$(git -C "/var/www/${DOMAIN}/source" log -1 --format='%ci' 2>/dev/null || echo "unknown") echo -e "# Last git: ${last_commit}" fi else echo -e "# Status: ${YELLOW}not deployed${NC}" fi echo echo -e "# ${BOLD}TLS Certificates${NC}" for d in "$DOMAIN"; do local cert="/etc/letsencrypt/live/${d}/fullchain.pem" if [[ -f "$cert" ]]; then local expiry expiry=$(openssl x509 -enddate -noout -in "$cert" 2>/dev/null | cut -d= -f2) echo -e "# ${d}: expires ${expiry}" else echo -e "# ${d}: ${YELLOW}no certificate${NC}" fi done echo echo -e "# ${BOLD}Firewall${NC}" if command -v ufw &>/dev/null; then local ufw_status ufw_status=$(ufw status 2>/dev/null | head -1) echo -e "# UFW: ${ufw_status}" elif command -v firewall-cmd &>/dev/null; then echo -e "# firewalld: $(firewall-cmd --state 2>/dev/null)" else echo -e "# Status: ${YELLOW}no firewall detected${NC}" fi echo echo -e "# ${BOLD}Fail2ban${NC}" if systemctl is-active --quiet fail2ban 2>/dev/null; then echo -e "# Status: ${GREEN}running${NC}" local jails jails=$(fail2ban-client status 2>/dev/null | grep "Jail list" | cut -d: -f2 | xargs) echo -e "# Jails: ${jails:-none}" else echo -e "# Status: ${RED}stopped${NC}" fi echo echo -e "# ${BOLD}CrowdSec${NC}" if systemctl is-active --quiet crowdsec 2>/dev/null; then echo -e "# Engine: ${GREEN}running${NC}" local decisions decisions=$(cscli decisions list -o raw 2>/dev/null | tail -n +2 | wc -l) echo -e "# Decisions: ${decisions}" else echo -e "# Engine: ${YELLOW}not installed${NC}" fi } # ============================================================================= # SUBCOMMAND: all # ============================================================================= cmd_all() { banner "Full Server Setup" if [[ -z "$DOMAIN" ]]; then die "--domain is required for the 'all' subcommand" fi cmd_base cmd_nginx if [[ "$SKIP_HUGO" == "true" ]]; then info "Skipping Hugo install (--skip-hugo) — site directory still created by nginx subcommand" else cmd_hugo fi if [[ "$SKIP_TLS" == "true" ]]; then info "Skipping TLS setup (--skip-tls) — run 'tls' subcommand after DNS is pointed" else cmd_tls fi cmd_security banner "Setup Complete" echo echo -e "# ${BOLD}Summary${NC}" echo -e "# Hugo site: https://${DOMAIN}" echo echo -e "# ${BOLD}Next Steps${NC}" echo -e "# 1. Clone your Hugo site repo to /var/www/${DOMAIN}/source" echo -e "# 2. Run deploy-site.sh to build" echo -e "# 3. Lock down SSH (key-only, disable root, change port)" echo } # ============================================================================= # MAIN # ============================================================================= main() { parse_args "$@" case "$SUBCOMMAND" in status) detect_os 2>/dev/null || true cmd_status ;; base|nginx|hugo|tls|security|all) require_root detect_os case "$SUBCOMMAND" in base) cmd_base ;; nginx) cmd_nginx ;; hugo) cmd_hugo ;; tls) cmd_tls ;; security) cmd_security ;; all) cmd_all ;; esac ;; *) die "Unknown subcommand: ${SUBCOMMAND}. Run with --help for usage." ;; esac } main "$@"