#!/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 # Apply global hardening options FIRST (before the Match block) 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 # Insert before the first Match block, or append if no Match block exists if grep -q "^Match " "$SSHD_CONFIG"; then sed -i "/^Match /i ${key} ${val}" "$SSHD_CONFIG" else echo "${key} ${val}" >> "$SSHD_CONFIG" fi fi } apply_ssh_option "PermitRootLogin" "prohibit-password" apply_ssh_option "PasswordAuthentication" "yes" apply_ssh_option "MaxAuthTries" "4" apply_ssh_option "LoginGraceTime" "30" # Append the Match block LAST printf '%s\n' "$SFTP_STANZA" >> "$SSHD_CONFIG" 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}"