Initial release

This commit is contained in:
Rodger Castle 2026-05-04 13:56:37 -04:00
commit 840177becb
2 changed files with 427 additions and 0 deletions

31
README.md Normal file
View File

@ -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

396
imagehost-setup.sh Normal file
View File

@ -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!@#%^&*()-_=+' </dev/urandom | head -c 24)"
GENERATED_PASS=true
else
GENERATED_PASS=false
fi
# ── Max upload file size ──────────────────────────────────────────────────────
echo ""
echo -e "${BOLD}Maximum image file size${RESET} in megabytes (leave blank for 20 MB):"
read -rp " Max size MB [20]: " MAX_SIZE_MB
MAX_SIZE_MB="${MAX_SIZE_MB:-20}"
[[ "$MAX_SIZE_MB" =~ ^[0-9]+$ ]] || die "Max size must be a whole number."
# ── Confirm ───────────────────────────────────────────────────────────────────
echo ""
echo -e "${BOLD}──────── Summary ────────${RESET}"
echo -e " SFTP username : ${CYAN}${SFTP_USER}${RESET}"
if $GENERATED_PASS; then
echo -e " SFTP password : ${YELLOW}(auto-generated — shown at the end)${RESET}"
else
echo -e " SFTP password : ${CYAN}(as entered)${RESET}"
fi
echo -e " Domain : ${CYAN}${DOMAIN:-'(none — IP only)'}${RESET}"
echo -e " SSL : ${CYAN}${USE_SSL}${RESET}"
echo -e " Max file size : ${CYAN}${MAX_SIZE_MB} MB${RESET}"
echo ""
read -rp "Proceed with installation? [y/N] " CONFIRM
[[ "${CONFIRM,,}" == "y" ]] || { echo "Aborted."; exit 0; }
# =============================================================================
# DERIVED VARIABLES
# =============================================================================
CHROOT_DIR="/srv/imagehost"
IMAGES_DIR="${CHROOT_DIR}/images"
NGINX_CONF="/etc/nginx/conf.d/imagehost.conf"
MAX_SIZE_NGINX="${MAX_SIZE_MB}m"
LOG_FILE="/var/log/imagehost-setup.log"
# All output also goes to a log file
exec > >(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" <<NGINXCONF
# imagehost — generated by imagehost-setup.sh
server {
listen 80;
listen [::]:80;
server_name ${SERVER_NAME};
root ${IMAGES_DIR};
autoindex off;
server_tokens off;
client_max_body_size ${MAX_SIZE_NGINX};
# Security headers
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "no-referrer" always;
add_header Content-Security-Policy "default-src 'none'; img-src 'self'" always;
# Allow only image file types
location ~* \.(jpe?g|png|gif|webp|avif|svg|ico|bmp|tiff?)$ {
expires 30d;
add_header Cache-Control "public, immutable";
add_header X-Content-Type-Options "nosniff" always;
try_files \$uri =404;
}
# Block everything else
location / {
return 404;
}
# Hide dot-files
location ~ /\. {
deny all;
}
access_log /var/log/nginx/imagehost_access.log;
error_log /var/log/nginx/imagehost_error.log;
}
NGINXCONF
# Remove default Nginx config if present
[[ -f /etc/nginx/conf.d/default.conf ]] && mv /etc/nginx/conf.d/default.conf /etc/nginx/conf.d/default.conf.bak
nginx -t || die "Nginx config test failed — check ${NGINX_CONF}"
systemctl enable --now nginx
success "Nginx configured and started."
# Nginx needs read access to the images directory (SELinux-aware)
info "Setting file contexts for SELinux…"
if command -v semanage &>/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 <<F2B
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 5
backend = systemd
[sshd]
enabled = true
port = ssh
logpath = %(sshd_log)s
[nginx-http-auth]
enabled = true
[nginx-limit-req]
enabled = true
F2B
systemctl enable --now fail2ban
success "fail2ban configured and started."
# =============================================================================
# STEP 7 — Let's Encrypt SSL (optional)
# =============================================================================
if $USE_SSL; then
banner "Step 7/7 — Obtaining SSL certificate"
info "Requesting certificate for ${DOMAIN}"
if certbot --nginx \
--non-interactive \
--agree-tos \
--email "$LE_EMAIL" \
--domains "$DOMAIN" \
--redirect; then
success "SSL certificate installed. HTTPS enabled."
# Ensure auto-renewal is active
systemctl enable --now certbot-renew.timer 2>/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}/<filename.jpg>${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}"