404 lines
16 KiB
Bash
404 lines
16 KiB
Bash
#!/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 || true)"
|
|
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
|
|
|
|
# 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" <<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}"
|