#!/usr/bin/env bash # 50-hardening — system defaults. Each sub-step y/N, all idempotent. step_name="hardening" step_desc="ssh, ufw, updates, unattended, tz, hostname, swap, locale, fail2ban, angie config + built-in ACME" step_run="run_hardening" run_hardening() { log "hardening system defaults" _h_ssh _h_ufw _h_updates _h_tz_hostname _h_swap _h_locale _h_fail2ban _h_angie_config _h_angie_acme ok "hardening pass complete" } # ── SSH: permit root key login, disable password auth ────────────────────── _h_ssh() { if ! command -v sshd >/dev/null 2>&1 && ! [[ -f /etc/ssh/sshd_config ]]; then warn "no sshd found — skipping SSH hardening"; return fi if ! yn "Harden sshd: PermitRootLogin prohibit-password, disable password auth? WARNING: only say yes if you can already log in with a public key, else you may lock yourself out. A backup will be made and sshd -t is run before reload. Continue?" n; then echo " skip ssh hardening"; return fi local cfg="/etc/ssh/sshd_config" sudo_ cp -a "$cfg" "${cfg}.bak.$(date +%Y%m%d-%H%M%S)" sshd_set() { # key value — replace or append a top-level directive local k="$1" v="$2" cfg="$3" if grep -qiE "^\s*#?\s*${k}\b" "$cfg"; then sudo_ sed -i -E "s|^\s*#?\s*${k}\b.*|${k} ${v}|" "$cfg" else echo "${k} ${v}" | sudo_ tee -a "$cfg" >/dev/null fi } sshd_set PermitRootLogin prohibit-password "$cfg" sshd_set PasswordAuthentication no "$cfg" sshd_set KbdInteractiveAuthentication no "$cfg" sshd_set PubkeyAuthentication yes "$cfg" if sudo_ sshd -t 2>/dev/null; then if command -v systemctl >/dev/null 2>&1 && systemctl is-active ssh sshd 2>/dev/null | grep -q active; then sudo_ systemctl reload ssh 2>/dev/null || sudo_ systemctl reload sshd 2>/dev/null || sudo_ systemctl restart ssh sshd 2>/dev/null else sudo_ systemctl restart ssh 2>/dev/null || sudo_ systemctl restart sshd 2>/dev/null || \ sudo_ service ssh restart 2>/dev/null || sudo_ service sshd restart 2>/dev/null || warn "could not reload sshd" fi ok "sshd hardened (root key-only, no passwords). Keep your current session open and test a new login before closing it." else err "sshd -t failed — restoring backup and aborting ssh hardening" sudo_ cp -a "${cfg}.bak.$(date +%Y%m%d-%H%M%S)" "$cfg" 2>/dev/null || true warn "ssh hardening aborted, config restored" fi } # ── ufw: deny incoming, allow 22 + mosh UDP + angie 80/443 ────────────────── _h_ufw() { command -v ufw >/dev/null 2>&1 || { warn "ufw not installed — skipping"; return; } if ! yn "Enable ufw (deny incoming, allow 22, mosh 60000:61000/udp, 80+443/tcp)?" y; then echo " skip ufw"; return fi sudo_ ufw --force reset >/dev/null 2>&1 || true sudo_ ufw default deny incoming sudo_ ufw default allow outgoing sudo_ ufw allow 22/tcp comment 'ssh' sudo_ ufw allow 60000:61000/udp comment 'mosh' sudo_ ufw allow 80/tcp comment 'angie http' sudo_ ufw allow 443/tcp comment 'angie https' sudo_ ufw --force enable ok "ufw enabled. status:" sudo_ ufw status verbose | sed 's/^/ /' } # ── updates + unattended-upgrades (debian) ───────────────────────────────── _h_updates() { if ! yn "Run a system upgrade now?" y; then echo " skip updates"; return; fi case "$DISTRO" in arch) sudo_ pacman -Syu --noconfirm ;; debian) sudo_ apt-get update -y sudo_ apt-get upgrade -y if yn "Enable unattended-upgrades (daily security auto-patches)?" y; then sudo_ apt-get install -y unattended-upgrades apt-listchanges sudo_ dpkg-reconfigure -f noninteractive unattended-upgrades 2>/dev/null || true ok "unattended-upgrades enabled" fi ;; esac } # ── timezone + hostname ───────────────────────────────────────────────────── _h_tz_hostname() { if command -v timedatectl >/dev/null 2>&1; then if yn "Set timezone (default UTC)?" y; then local tz="${REPLY_TZ:-}" if (( ${ALL:-0} )); then tz="UTC"; ok "--yes: timezone defaulting to UTC" else read -rp " timezone [UTC]: " tz [[ -z "$tz" ]] && tz="UTC" fi sudo_ timedatectl set-timezone "$tz" && ok "timezone set to $tz" fi else echo " timedatectl missing — skipping tz" fi if yn "Set a hostname now? (optional)" n; then read -rp " hostname: " hn if [[ -n "$hn" ]] && command -v hostnamectl >/dev/null 2>&1; then sudo_ hostnamectl set-hostname "$hn" && ok "hostname set to $hn" fi fi } # ── swapfile if none and RAM is low ───────────────────────────────────────── _h_swap() { if [[ "$(swapon --show --noheadings | wc -l)" -gt 0 ]]; then ok "swap already present — skipping"; return fi local mem_mb; mem_mb="$(awk '/MemTotal/ {printf "%d", $2/1024}' /proc/meminfo)" if (( mem_mb > 2048 )); then echo " RAM ${mem_mb}MB > 2GB — skipping swapfile"; return fi if ! yn "Create a 2G swapfile (RAM is ${mem_mb}MB)?" y; then echo " skip swap"; return; fi sudo_ fallocate -l 2G /swapfile || sudo_ dd if=/dev/zero of=/swapfile bs=1M count=2048 sudo_ chmod 600 /swapfile sudo_ mkswap /swapfile sudo_ swapon /swapfile if ! grep -q '^/swapfile' /etc/fstab; then echo '/swapfile none swap sw 0 0' | sudo_ tee -a /etc/fstab >/dev/null fi ok "2G swapfile created and enabled" } # ── locale en_US.UTF-8 ─────────────────────────────────────────────────────── _h_locale() { case "$DISTRO" in arch) if ! grep -q '^en_US.UTF-8' /etc/locale.gen 2>/dev/null; then echo " localegen already has en_US.UTF-8 or file absent — touching" fi if [[ -f /etc/locale.gen ]]; then sudo_ sed -i 's/^#en_US.UTF-8/en_US.UTF-8/' /etc/locale.gen sudo_ locale-gen fi ;; debian) if ! locale -a 2>/dev/null | grep -qi 'en_US.utf8'; then echo 'en_US.UTF-8 UTF-8' | sudo_ tee -a /etc/locale.gen >/dev/null sudo_ locale-gen ok "generated en_US.UTF-8" else ok "en_US.UTF-8 already generated" fi ;; esac } # ── fail2ban (best-effort) ─────────────────────────────────────────────────── _h_fail2ban() { if ! yn "Install + enable fail2ban (sshd jail by default)?" y; then echo " skip fail2ban"; return; fi case "$DISTRO" in arch) sudo_ pacman -Sy --noconfirm --needed fail2ban ;; debian) sudo_ apt-get install -y fail2ban ;; esac # local jail for sshd (works across distros) sudo_ tee /etc/fail2ban/jail.local >/dev/null <<'EOF' [DEFAULT] backend = systemd bantime = 1h findtime = 10m maxretry = 5 [sshd] enabled = true EOF if command -v systemctl >/dev/null 2>&1 && systemctl is-system-running >/dev/null 2>&1; then sudo_ systemctl enable --now fail2ban 2>/dev/null || warn "could not enable fail2ban" fi ok "fail2ban installed + sshd jail enabled" } # ── angie config: _on/ targets/ modules/ + sane root config ──────────────── _h_angie_config() { if ! command -v angie >/dev/null 2>&1; then echo " skip angie config (angie not installed — run the angie step first)"; return fi if ! yn "Set up Angie config layout (/etc/angie/_on targets modules) + sane root config?" y; then echo " skip angie config"; return fi local d=/etc/angie sudo_ install -d -m 0755 "$d/_on" "$d/targets" "$d/modules" "$d/modules/http" # ── extract packaging-specific values from the stock angie.conf ── # Different Angie packages set user/pid/error_log differently (Arch source # package → /etc/nginx/nginx.conf with 'user http'; angie-bin / Debian .deb # → /etc/angie/angie.conf with 'user angie'). Preserve them when present, # fall back to sane defaults otherwise (defaults cover all distros here). local oldcfg="$d/angie.conf" local altcfg="/etc/nginx/nginx.conf" # the source AUR package's path local stock="$oldcfg" [[ -f "$oldcfg" ]] || stock="$altcfg" local angie_user="" angie_pid="" angie_err="" angie_modules="" if [[ -f "$stock" ]]; then angie_user=$(grep -E '^\s*user\s+' "$stock" | head -1 | awk '{print $2}' | tr -d ';') angie_pid=$(grep -E '^\s*pid\s+' "$stock" | head -1 | awk '{print $2}' | tr -d ';') angie_err=$(grep -E '^\s*error_log\s+' "$stock" | head -1 | awk '{print $2}' | tr -d ';') # collect any load_module lines (dynamic modules) angie_modules=$(grep -E '^\s*load_module\s+' "$stock" || true) fi # sane defaults if not found [[ -z "$angie_user" ]] && angie_user=$({ [[ "${DISTRO:-}" == "arch" ]] && echo http || echo angie; }) [[ -z "$angie_pid" ]] && angie_pid=/run/angie.pid [[ -z "$angie_err" ]] && angie_err=/var/log/angie/error.log # locate mime.types: angie-bin/Debian ship /etc/angie/mime.types; the Arch source # 'angie' package instead pulls it from the 'mailcap' dep at /etc/nginx/mime.types # (and deletes its own). Pick whichever exists so 'angie -t' doesn't fail on include. local angie_mime="" for m in "$d/mime.types" /etc/nginx/mime.types /etc/mime.types; do [[ -f "$m" ]] && { angie_mime="$m"; break; } done [[ -z "$angie_mime" ]] && { warn "no mime.types found; installing a minimal one"; _angie_install_mime "$d/mime.types"; angie_mime="$d/mime.types"; } # the modules/ include dir must exist or the glob fails the config test sudo_ install -d -m 0755 "$d/modules" "$d/modules/http" # backup the original once if [[ -f "$oldcfg" && ! -f "$oldcfg.orig.bootstrap" ]]; then sudo_ cp -a "$oldcfg" "$oldcfg.orig.bootstrap" fi # ── write our root angie.conf (once — guarded, won't clobber later edits) ── if grep -q '# managed by bootstrap' "$oldcfg" 2>/dev/null; then ok "$oldcfg already bootstrap-managed — leaving it (keeps your edits)" else sudo_ tee "$oldcfg" >/dev/null </dev/null <<'EOF' # real_ip — trust private ranges + (commented Cloudflare). Uncomment/edit for your upstream. set_real_ip_from 10.0.0.0/8; set_real_ip_from 172.16.0.0/12; set_real_ip_from 192.168.0.0/16; set_real_ip_from 169.254.0.0/16; # set_real_ip_from 173.245.48.0/20; # Cloudflare, see https://www.cloudflare.com/ips/ real_ip_header X-Forwarded-For; real_ip_recursive on; # proxy defaults (inherited by every server/location unless overridden) proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; EOF fi # ── default catch-all server: unmatched Host -> drop ── if [[ ! -f "$d/_on/00-default.conf" ]]; then sudo_ tee "$d/_on/00-default.conf" >/dev/null <<'EOF' # default_server: drop requests that match no enabled host (drive-by scanners, bare-IP) server { listen 80 default_server; listen [::]:80 default_server; listen 443 default_server; listen [::]:443 default_server; server_name _; return 444; } EOF fi # ── angie-enable / angie-disable helpers ── _angie_helpers # ── source-angie bridge: point /etc/nginx/nginx.conf at our /etc/angie layout ─ # The source AUR 'angie' package compiles --conf-path=/etc/nginx/nginx.conf and # its service runs '/usr/bin/nginx' with no -c flag, so it ignores /etc/angie. # When that path is where angie actually reads, back it up once and symlink it to # our managed angie.conf so the _on/targetsmodules layout takes effect. The # angie-bin / Debian packages already read /etc/angie/angie.conf directly. if [[ -f /etc/nginx/nginx.conf && -f "$d/angie.conf" ]]; then local nginxconf=/etc/nginx/nginx.conf if [[ "$(readlink -f "$nginxconf" 2>/dev/null)" == "$d/angie.conf" ]]; then ok "$nginxconf already symlinks to $d/angie.conf" else if [[ ! -f "${nginxconf}.orig.bootstrap" && ! -L "$nginxconf" ]]; then sudo_ cp -a "$nginxconf" "${nginxconf}.orig.bootstrap" fi sudo_ ln -sfn "$d/angie.conf" "$nginxconf" ok "symlinked $nginxconf -> $d/angie.conf (source-angie bridge); backup at ${nginxconf}.orig.bootstrap" fi fi if sudo_ angie -t 2>&1; then ok "angie config valid" if command -v systemctl >/dev/null 2>&1 && systemctl is-active angie >/dev/null 2>&1; then sudo_ systemctl reload angie 2>/dev/null && ok "angie reloaded" else echo " start angie when ready: sudo systemctl enable --now angie" fi else err "angie -t failed — review $d/angie.conf (backup at $oldcfg.orig.bootstrap)" fi ok "angie layout ready: $d/{angie.conf,_on/,targets/,modules/,modules/http/}" echo " enable a site: angie-enable (after writing $d/targets/.conf)" echo " disable a site: angie-disable " # ufw already opened 80/443 in _h_ufw } _angie_install_mime() { # dest — write a minimal mime.types if none exists on the host sudo_ tee "$1" >/dev/null <<'EOF' types { text/html html htm shtml; text/css css; text/xml xml; image/gif gif; image/jpeg jpeg jpg; image/png png; image/svg+xml svg svgz; application/javascript js; application/json json; application/pdf pdf; application/zip zip; application/octet-stream bin exe dll so deb dmg iso img; } EOF } _angie_helpers() { sudo_ tee /usr/local/bin/angie-enable >/dev/null <<'EOF' #!/usr/bin/env bash # angie-enable — symlink targets/.conf into _on/ and validate. set -euo pipefail name="${1:-}" [ -n "$name" ] || { echo "usage: angie-enable " >&2; exit 2; } target="/etc/angie/targets/${name}.conf" link="/etc/angie/_on/${name}.conf" [ -f "$target" ] || { echo "no such target: $target" >&2; exit 1; } ln -sfn "../targets/${name}.conf" "$link" echo "enabled: $name -> $link" if command -v angie >/dev/null 2>&1; then sudo angie -t || { echo "config invalid — review before reload" >&2; exit 1; } sudo systemctl reload angie && echo "reloaded" fi EOF sudo_ tee /usr/local/bin/angie-disable >/dev/null <<'EOF' #!/usr/bin/env bash # angie-disable — remove the _on/.conf symlink and reload. set -euo pipefail name="${1:-}" [ -n "$name" ] || { echo "usage: angie-disable " >&2; exit 2; } link="/etc/angie/_on/${name}.conf" [ -L "$link" ] || [ -e "$link" ] || { echo "$name not enabled ($link missing)" >&2; exit 1; } rm -f "$link" echo "disabled: $name" if command -v angie >/dev/null 2>&1; then sudo angie -t || true sudo systemctl reload angie && echo "reloaded" fi EOF sudo_ chmod +x /usr/local/bin/angie-enable /usr/local/bin/angie-disable } # ── angie built-in ACME (Let's Encrypt) ──────────────────────────────────── # No certbot: Angie ships http_acme and fetches/renews certs itself. # Bootstrap can't know your domain/email, so we ship an example TLS server # target + an `angie-issue` helper that creates the live acme_client on demand. _h_angie_acme() { if ! command -v angie >/dev/null 2>&1; then echo " skip angie ACME (angie not installed)"; return fi if ! yn "Ship Angie built-in ACME template + angie-issue helper?" y; then echo " skip angie ACME"; return fi local d=/etc/angie # example TLS server target — NOT auto-enabled (.example, so angie-enable won't glob it) if [[ ! -f "$d/targets/example-https.conf.example" ]]; then sudo_ tee "$d/targets/example-https.conf.example" >/dev/null <<'EOF' # Template for a TLS host using Angie built-in ACME. # Use it via the helper: angie-issue [you@email] # Or by hand: copy to targets/.conf, fill , then: angie-enable server { listen 80; listen [::]:80; listen 443 ssl; listen [::]:443 ssl; http2 on; server_name ; acme default; # add this server_name to the shared 'default' ACME certificate ssl_certificate $acme_cert_default; ssl_certificate_key $acme_cert_key_default; # http -> https redirect if ($scheme = http) { return 301 https://$host$request_uri; } location / { # replace with your app / proxy_pass upstream; return 200 "angie + builtin ACME ok\n"; add_header Content-Type text/plain; } } EOF fi sudo_ tee /usr/local/bin/angie-issue >/dev/null <<'EOF' #!/usr/bin/env bash # angie-issue [email] — create a TLS host backed by Angie built-in ACME. # First call (needs email) writes /etc/angie/modules/http/acme.conf with the # shared 'default' acme_client; every call writes targets/.conf and enables it. # All enabled domains using `acme default` share one cert covering all their server_names. set -euo pipefail domain="${1:-}"; email="${2:-}" [ -n "$domain" ] || { echo "usage: angie-issue [email]" >&2; exit 2; } acme_conf=/etc/angie/modules/http/acme.conf if [ ! -f "$acme_conf" ]; then [ -n "$email" ] || { echo "first-time: also pass your email for Let's Encrypt" >&2; exit 2; } cat > "$acme_conf" <|${domain}|g" /etc/angie/targets/example-https.conf.example > "$target" echo "wrote $target" ln -sfn "../targets/${domain}.conf" /etc/angie/_on/${domain}.conf echo "enabled: $domain" sudo angie -t sudo systemctl reload angie && echo "angie reloaded — certificate is obtained automatically." EOF sudo_ chmod +x /usr/local/bin/angie-issue ok "angie ACME ready: example host at $d/targets/example-https.conf.example" echo " issue a cert: angie-issue you@email" echo " then reload picks it up; Angie renews automatically before expiry." }