commit 840177becbc39aa8f897595ba12dfd634943144d Author: Rodger Castle Date: Mon May 4 13:56:37 2026 -0400 Initial release diff --git a/README.md b/README.md new file mode 100644 index 0000000..1da162f --- /dev/null +++ b/README.md @@ -0,0 +1,31 @@ +# imagehost-setup + +A single-script installer that turns a fresh AlmaLinux 10 VPS into a +lightweight, secure image host — SFTP upload, Nginx serving, optional +Let's Encrypt SSL, and fail2ban out of the box. + +## Requirements + +- A fresh AlmaLinux 10 VPS +- Root or sudo access + +## Usage + +Download and run the script: + + curl -O https://git.castlehollow.com/youruser/imagehost-setup/raw/branch/main/imagehost-setup.sh + sudo bash imagehost-setup.sh + +The script will walk you through the rest interactively. + +## What it sets up + +- **Nginx** — serves image files only (JPG, PNG, GIF, WebP, AVIF, SVG, BMP, TIFF) +- **SFTP chroot** — a locked-down upload user with no shell access +- **firewalld** — opens only SSH, HTTP, and HTTPS +- **fail2ban** — brute-force protection on SSH and Nginx +- **Let's Encrypt SSL** — optional, with HTTP fallback + +## License + +MIT diff --git a/imagehost-setup.sh b/imagehost-setup.sh new file mode 100644 index 0000000..7706d1b --- /dev/null +++ b/imagehost-setup.sh @@ -0,0 +1,396 @@ +#!/usr/bin/env bash +# ============================================================================= +# imagehost-setup.sh +# Turns a fresh AlmaLinux 10 VPS into a lightweight, secure image host. +# - Nginx (image-type-restricted static file serving) +# - Chrooted SFTP-only upload user +# - firewalld rules +# - fail2ban +# - Optional Let's Encrypt SSL (falls back to plain HTTP) +# +# Usage: sudo bash imagehost-setup.sh +# ============================================================================= + +set -euo pipefail + +# ── Colour helpers ──────────────────────────────────────────────────────────── +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' +CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m' + +info() { echo -e "${CYAN}[INFO]${RESET} $*"; } +success() { echo -e "${GREEN}[OK]${RESET} $*"; } +warn() { echo -e "${YELLOW}[WARN]${RESET} $*"; } +die() { echo -e "${RED}[ERROR]${RESET} $*" >&2; exit 1; } +banner() { echo -e "\n${BOLD}${CYAN}══════════════════════════════════════════════${RESET}"; \ + echo -e "${BOLD}${CYAN} $*${RESET}"; \ + echo -e "${BOLD}${CYAN}══════════════════════════════════════════════${RESET}\n"; } + +# ── Root check ──────────────────────────────────────────────────────────────── +[[ $EUID -eq 0 ]] || die "This script must be run as root (use: sudo bash imagehost-setup.sh)" + +# ── Detect AlmaLinux ───────────────────────────────────────────────────────── +if ! grep -qi 'almalinux' /etc/os-release 2>/dev/null; then + warn "This script is designed for AlmaLinux. Proceeding anyway, but results may vary." +fi + +# ============================================================================= +# INTERACTIVE CONFIGURATION +# ============================================================================= +banner "Image Host Setup — Configuration" + +# ── Domain ──────────────────────────────────────────────────────────────────── +echo -e "${BOLD}Domain name${RESET} (leave blank to use the server's IP address only):" +read -rp " Domain [none]: " DOMAIN +DOMAIN="${DOMAIN// /}" # strip spaces + +if [[ -n "$DOMAIN" ]]; then + echo -e "${BOLD}Email address${RESET} for Let's Encrypt certificate notices:" + read -rp " Email: " LE_EMAIL + LE_EMAIL="${LE_EMAIL// /}" + [[ "$LE_EMAIL" =~ ^[^@]+@[^@]+\.[^@]+$ ]] || die "Invalid email address." + USE_SSL=true +else + USE_SSL=false + info "No domain provided — will serve over HTTP only." +fi + +# ── SFTP user ───────────────────────────────────────────────────────────────── +echo "" +echo -e "${BOLD}SFTP username${RESET} for image uploads (leave blank to use 'imageuser'):" +read -rp " Username [imageuser]: " SFTP_USER +SFTP_USER="${SFTP_USER:-imageuser}" +# Validate username +[[ "$SFTP_USER" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] || die "Invalid username. Use lowercase letters, numbers, hyphens, underscores." + +echo "" +echo -e "${BOLD}SFTP password${RESET} (leave blank to auto-generate a strong password):" +read -rsp " Password [auto]: " SFTP_PASS +echo "" + +if [[ -z "$SFTP_PASS" ]]; then + SFTP_PASS="$(tr -dc 'A-Za-z0-9!@#%^&*()-_=+' >(tee -a "$LOG_FILE") 2>&1 + +# ============================================================================= +# STEP 1 — System update & packages +# ============================================================================= +banner "Step 1/7 — Installing packages" + +info "Updating system packages…" +dnf -y update --quiet + +info "Installing EPEL repository…" +dnf -y install epel-release --quiet + +info "Installing Nginx, fail2ban, certbot, and utilities…" +dnf -y install nginx fail2ban firewalld pwgen --quiet + +if $USE_SSL; then + dnf -y install certbot python3-certbot-nginx --quiet +fi + +success "Packages installed." + +# ============================================================================= +# STEP 2 — Create chroot jail + SFTP user +# ============================================================================= +banner "Step 2/7 — Creating SFTP user and chroot jail" + +# OpenSSH chroot requires the chroot root owned by root:root, mode 755 +info "Creating chroot directory structure…" +mkdir -p "${IMAGES_DIR}" +chown root:root "${CHROOT_DIR}" +chmod 755 "${CHROOT_DIR}" + +# The images subdirectory is owned by the SFTP user so they can write to it +if id "${SFTP_USER}" &>/dev/null; then + warn "User '${SFTP_USER}' already exists — skipping user creation." +else + info "Creating user '${SFTP_USER}'…" + useradd -r -s /sbin/nologin -d "${CHROOT_DIR}" -M "${SFTP_USER}" +fi + +echo "${SFTP_USER}:${SFTP_PASS}" | chpasswd + +chown "${SFTP_USER}:${SFTP_USER}" "${IMAGES_DIR}" +chmod 750 "${IMAGES_DIR}" + +success "User '${SFTP_USER}' created with chroot at ${CHROOT_DIR}." + +# ============================================================================= +# STEP 3 — Harden SSH / configure chroot SFTP +# ============================================================================= +banner "Step 3/7 — Configuring SSH & SFTP chroot" + +SSHD_CONFIG="/etc/ssh/sshd_config" +SFTP_STANZA=" +# ── imagehost SFTP chroot ────────────────────────────────────────────────── +Match User ${SFTP_USER} + ChrootDirectory ${CHROOT_DIR} + ForceCommand internal-sftp + AllowTcpForwarding no + X11Forwarding no + PermitTunnel no + AllowAgentForwarding no +" + +# Remove any previous stanza for this user, then append the new one +if grep -q "Match User ${SFTP_USER}" "$SSHD_CONFIG"; then + info "Removing existing SSH stanza for '${SFTP_USER}'…" + # Use python for reliable multi-line deletion + python3 - "${SFTP_USER}" "$SSHD_CONFIG" <<'PYEOF' +import sys, re, pathlib +user = sys.argv[1] +path = pathlib.Path(sys.argv[2]) +text = path.read_text() +pattern = rf'\n# ── imagehost SFTP chroot ──.*?Match User {re.escape(user)}.*?AllowAgentForwarding no\n' +text = re.sub(pattern, '', text, flags=re.DOTALL) +path.write_text(text) +PYEOF +fi + +printf '%s\n' "$SFTP_STANZA" >> "$SSHD_CONFIG" + +# Additional SSH hardening (idempotent: only add if not already set) +apply_ssh_option() { + local key="$1" val="$2" + if grep -qiE "^\s*${key}\s" "$SSHD_CONFIG"; then + sed -i -E "s|^\s*${key}\s.*|${key} ${val}|i" "$SSHD_CONFIG" + else + echo "${key} ${val}" >> "$SSHD_CONFIG" + fi +} +apply_ssh_option "PermitRootLogin" "prohibit-password" +apply_ssh_option "PasswordAuthentication" "yes" # needed for SFTP password auth +apply_ssh_option "MaxAuthTries" "4" +apply_ssh_option "LoginGraceTime" "30" + +sshd -t || die "SSH config test failed — check ${SSHD_CONFIG}" +systemctl restart sshd +success "SSH/SFTP configured." + +# ============================================================================= +# STEP 4 — Nginx configuration +# ============================================================================= +banner "Step 4/7 — Configuring Nginx" + +# Determine server_name +if [[ -n "$DOMAIN" ]]; then + SERVER_NAME="$DOMAIN" +else + # Use the primary non-loopback IP + SERVER_NAME=$(hostname -I | awk '{print $1}') +fi + +cat > "$NGINX_CONF" </dev/null && command -v restorecon &>/dev/null; then + semanage fcontext -a -t httpd_sys_content_t "${CHROOT_DIR}(/.*)?" 2>/dev/null || \ + semanage fcontext -m -t httpd_sys_content_t "${CHROOT_DIR}(/.*)?." 2>/dev/null || true + restorecon -Rv "${CHROOT_DIR}" >/dev/null + # Allow Nginx to read the chroot dir + setsebool -P httpd_read_user_content 1 2>/dev/null || true + success "SELinux contexts applied." +else + warn "semanage not found — skipping SELinux context setup (may not be needed)." +fi + +# ============================================================================= +# STEP 5 — Firewall +# ============================================================================= +banner "Step 5/7 — Configuring firewall" + +systemctl enable --now firewalld + +firewall-cmd --quiet --permanent --add-service=ssh +firewall-cmd --quiet --permanent --add-service=http +$USE_SSL && firewall-cmd --quiet --permanent --add-service=https +firewall-cmd --quiet --reload +success "Firewall rules applied (SSH + HTTP${USE_SSL:+ + HTTPS})." + +# ============================================================================= +# STEP 6 — fail2ban +# ============================================================================= +banner "Step 6/7 — Configuring fail2ban" + +cat > /etc/fail2ban/jail.d/imagehost.conf </dev/null || \ + (crontab -l 2>/dev/null; echo "0 3 * * * certbot renew --quiet") | crontab - + else + warn "Certbot failed — falling back to HTTP." + warn "Make sure your domain's DNS A record points to this server's IP." + warn "You can re-run certbot manually later:" + warn " certbot --nginx -d ${DOMAIN} --email ${LE_EMAIL} --agree-tos --redirect" + USE_SSL=false + fi +else + banner "Step 7/7 — SSL skipped (no domain provided)" + info "Serving over HTTP only." +fi + +# ============================================================================= +# FINAL SUMMARY +# ============================================================================= +banner "Setup Complete!" + +# Determine the base URL +if $USE_SSL; then + BASE_URL="https://${DOMAIN}" +elif [[ -n "$DOMAIN" ]]; then + BASE_URL="http://${DOMAIN}" +else + IP=$(hostname -I | awk '{print $1}') + BASE_URL="http://${IP}" +fi + +echo -e "${BOLD}Your image host is ready.${RESET}" +echo "" +echo -e " ${BOLD}Image URL format:${RESET}" +echo -e " ${CYAN}${BASE_URL}/${RESET}" +echo "" +echo -e " ${BOLD}SFTP connection details:${RESET}" +echo -e " Host : ${CYAN}${DOMAIN:-$(hostname -I | awk '{print $1}')}${RESET}" +echo -e " Port : ${CYAN}22${RESET}" +echo -e " Username : ${CYAN}${SFTP_USER}${RESET}" +if $GENERATED_PASS; then +echo -e " Password : ${YELLOW}${SFTP_PASS}${RESET} ${RED}← Save this now!${RESET}" +else +echo -e " Password : ${CYAN}(as you entered)${RESET}" +fi +echo -e " Upload to: ${CYAN}/images/${RESET} (this is the root you'll see in your SFTP client)" +echo "" +echo -e " ${BOLD}Allowed file types:${RESET} JPG, PNG, GIF, WebP, AVIF, SVG, BMP, TIFF" +echo -e " ${BOLD}Max file size:${RESET} ${MAX_SIZE_MB} MB" +echo "" +echo -e " ${BOLD}Recommended SFTP clients:${RESET}" +echo -e " • FileZilla (Windows / Mac / Linux) — free" +echo -e " • Cyberduck (Windows / Mac) — free" +echo -e " • WinSCP (Windows) — free" +echo "" +echo -e " ${BOLD}Setup log:${RESET} ${LOG_FILE}" +echo "" +echo -e "${GREEN}${BOLD}All done!${RESET}"