892 lines
28 KiB
Bash
892 lines
28 KiB
Bash
#!/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 <subcommand> [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 <<JAILEOF
|
|
[DEFAULT]
|
|
bantime = 3600
|
|
findtime = 600
|
|
maxretry = 3
|
|
|
|
[sshd]
|
|
enabled = true
|
|
logpath = ${logpath}
|
|
JAILEOF
|
|
|
|
systemctl enable --now fail2ban
|
|
info "fail2ban configured for SSH"
|
|
fi
|
|
|
|
# --- Unattended upgrades ---
|
|
step "Configuring unattended upgrades"
|
|
if [[ "$DRY_RUN" == "true" ]]; then
|
|
info "[DRY RUN] Would enable unattended upgrades"
|
|
else
|
|
case "$OS" in
|
|
ubuntu|debian)
|
|
apt install -y unattended-upgrades
|
|
dpkg-reconfigure -plow unattended-upgrades 2>/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 <<NGXEOF
|
|
user www-data;
|
|
worker_processes auto;
|
|
worker_rlimit_nofile 65536;
|
|
pid /run/nginx.pid;
|
|
|
|
include /etc/nginx/modules-enabled/*.conf;
|
|
|
|
error_log /var/log/nginx/error.log warn;
|
|
|
|
events {
|
|
worker_connections ${connections};
|
|
multi_accept on;
|
|
use epoll;
|
|
}
|
|
|
|
http {
|
|
include /etc/nginx/mime.types;
|
|
default_type application/octet-stream;
|
|
|
|
log_format main '\$remote_addr - \$remote_user [\$time_local] '
|
|
'"\$request" \$status \$body_bytes_sent '
|
|
'"\$http_referer" "\$http_user_agent" '
|
|
'ssl=\$ssl_protocol rt=\$request_time '
|
|
'\$geoip2_country_code \"\$geoip2_asn_org\"';
|
|
access_log /var/log/nginx/access.log main;
|
|
|
|
sendfile on;
|
|
tcp_nopush on;
|
|
tcp_nodelay on;
|
|
keepalive_timeout 65;
|
|
keepalive_requests 1000;
|
|
|
|
client_body_timeout 30;
|
|
client_header_timeout 30;
|
|
send_timeout 30;
|
|
client_body_buffer_size 16k;
|
|
client_max_body_size 50m;
|
|
|
|
gzip on;
|
|
gzip_vary on;
|
|
gzip_proxied any;
|
|
gzip_comp_level 5;
|
|
gzip_min_length 256;
|
|
gzip_types text/plain text/css text/javascript
|
|
application/javascript application/json
|
|
application/xml application/rss+xml
|
|
image/svg+xml;
|
|
|
|
server_tokens off;
|
|
|
|
# Drop requests with no matching server_name
|
|
server {
|
|
listen 80 default_server;
|
|
server_name _;
|
|
return 444;
|
|
}
|
|
|
|
include /etc/nginx/conf.d/*.conf;
|
|
}
|
|
NGXEOF
|
|
|
|
# Ensure www-data user exists
|
|
id www-data &>/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" <<SITEEOF
|
|
server {
|
|
listen 80;
|
|
server_name ${DOMAIN} www.${DOMAIN};
|
|
|
|
root /var/www/${DOMAIN}/public;
|
|
index index.html;
|
|
|
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
|
add_header X-Content-Type-Options "nosniff" always;
|
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
|
|
|
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff2?|ttf|eot)$ {
|
|
expires 30d;
|
|
add_header Cache-Control "public, immutable";
|
|
access_log off;
|
|
}
|
|
|
|
location / {
|
|
try_files \$uri \$uri/ =404;
|
|
}
|
|
|
|
location ~ /\. {
|
|
deny all;
|
|
}
|
|
|
|
error_page 404 /404.html;
|
|
}
|
|
SITEEOF
|
|
info "Created ${DOMAIN} server block"
|
|
fi
|
|
|
|
# --- Start nginx ---
|
|
if [[ "$DRY_RUN" == "false" ]]; then
|
|
systemctl enable nginx
|
|
nginx_test_and_reload
|
|
info "nginx installed and running"
|
|
fi
|
|
}
|
|
|
|
# =============================================================================
|
|
# SUBCOMMAND: hugo
|
|
# =============================================================================
|
|
|
|
cmd_hugo() {
|
|
banner "Hugo Installation"
|
|
|
|
local arch
|
|
arch=$(detect_arch)
|
|
|
|
# --- Resolve version ---
|
|
local version="$HUGO_VERSION"
|
|
if [[ "$version" == "latest" ]]; then
|
|
step "Resolving latest Hugo version"
|
|
version=$(curl -fsSL https://api.github.com/repos/gohugoio/hugo/releases/latest | \
|
|
grep -oP '"tag_name":\s*"v\K[^"]+' | head -1)
|
|
[[ -z "$version" ]] && die "Could not resolve latest Hugo version"
|
|
info "Latest Hugo version: ${version}"
|
|
fi
|
|
|
|
# --- Install Hugo ---
|
|
step "Installing Hugo extended ${version}"
|
|
if [[ "$DRY_RUN" == "true" ]]; then
|
|
info "[DRY RUN] Would install Hugo extended ${version}"
|
|
else
|
|
if command -v hugo &>/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 <<DEPLOYEOF
|
|
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
SITE_DIR="/var/www/${DOMAIN}"
|
|
BRANCH="\${1:-main}"
|
|
|
|
if [[ ! -d "\$SITE_DIR/source/.git" ]]; then
|
|
echo "# No git repo in \$SITE_DIR/source — clone your repo first:"
|
|
echo "# git clone --depth 1 YOUR_REPO_URL \$SITE_DIR/source"
|
|
exit 1
|
|
fi
|
|
|
|
cd "\$SITE_DIR/source"
|
|
git fetch origin "\$BRANCH"
|
|
git reset --hard "origin/\$BRANCH"
|
|
hugo --minify --destination "\$SITE_DIR/public"
|
|
echo "# Deploy complete: \$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
DEPLOYEOF
|
|
chmod +x /usr/local/bin/deploy-site.sh
|
|
info "Deploy script created at /usr/local/bin/deploy-site.sh"
|
|
fi
|
|
|
|
info "Hugo setup complete"
|
|
}
|
|
|
|
# =============================================================================
|
|
# SUBCOMMAND: tls
|
|
# =============================================================================
|
|
|
|
cmd_tls() {
|
|
banner "TLS Certificate Setup"
|
|
|
|
step "Installing certbot"
|
|
if [[ "$DRY_RUN" == "true" ]]; then
|
|
info "[DRY RUN] Would install certbot and obtain certificates"
|
|
else
|
|
case "$OS" in
|
|
ubuntu|debian)
|
|
apt install -y certbot python3-certbot-nginx
|
|
;;
|
|
*)
|
|
$PKG_MGR install certbot python3-certbot-nginx
|
|
;;
|
|
esac
|
|
|
|
step "Obtaining certificate for ${DOMAIN}"
|
|
certbot --nginx -d "${DOMAIN}" -d "www.${DOMAIN}" \
|
|
--non-interactive --agree-tos --email "${CERTBOT_EMAIL}" \
|
|
--redirect 2>&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 "$@"
|