main/modules/50-hardening.sh

522 lines
21 KiB
Bash
Executable File

#!/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 <<EOF
# angie.conf — managed by bootstrap. Original backup: angie.conf.orig.bootstrap
# Edit freely; re-running the hardening step will NOT overwrite (it writes once).
user $angie_user;
worker_processes auto;
pid $angie_pid;
error_log $angie_err warn;
# dynamic modules carried over from the original package config (if any)
$angie_modules
# top-level context snippets (stream{}, env, etc.)
include /etc/angie/modules/*.conf;
events {
worker_connections 1024;
}
http {
include $angie_mime;
default_type application/octet-stream;
log_format main '\$remote_addr - \$remote_user [\$time_local] "\$request" '
'\$status \$body_bytes_sent "\$http_referer" "\$http_user_agent"';
access_log /var/log/angie/access.log main;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
server_tokens off;
client_max_body_size 16m;
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 5;
gzip_min_length 256;
gzip_types text/plain text/css application/json application/javascript
text/xml application/xml application/xml+rss text/javascript;
# http-level snippets: upstreams, maps, real_ip, proxy defaults
include /etc/angie/modules/http/*.conf;
# enabled hosts (symlinks from targets/*). _on/*.conf only — targets/ is staging.
include /etc/angie/_on/*.conf;
}
EOF
fi
# ── http-level common snippet (real_ip + proxy defaults) ──
if [[ ! -f "$d/modules/http/00-common.conf" ]]; then
sudo_ tee "$d/modules/http/00-common.conf" >/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
# The default_server also listens on 443 to catch unknown-SNI TLS clients.
# It needs SOME cert to complete the handshake before `return 444` drops them;
# angie won't start a 443 listener without ssl_certificate. Generate a self-signed
# dummy once (CN='bootstrap-default', 10y) so unknown-SNI clients get dropped
# after connecting while real vhosts are picked by SNI.
if [[ ! -f "$d/ssl/bootstrap-default.crt" ]]; then
sudo_ install -d -m 0755 "$d/ssl"
sudo_ openssl req -x509 -newkey rsa:2048 -nodes -days 3650 -keyout "$d/ssl/bootstrap-default.key" -out "$d/ssl/bootstrap-default.crt" -subj "/CN=bootstrap-default/O=bootstrap" 2>/dev/null
fi
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 ssl default_server;
listen [::]:443 ssl default_server;
server_name _;
ssl_certificate /etc/angie/ssl/bootstrap-default.crt;
ssl_certificate_key /etc/angie/ssl/bootstrap-default.key;
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 <name> (after writing $d/targets/<name>.conf)"
echo " disable a site: angie-disable <name>"
# 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 <name> — symlink targets/<name>.conf into _on/ and validate.
set -euo pipefail
name="${1:-}"
[ -n "$name" ] || { echo "usage: angie-enable <name>" >&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 <name> — remove the _on/<name>.conf symlink and reload.
set -euo pipefail
name="${1:-}"
[ -n "$name" ] || { echo "usage: angie-disable <name>" >&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 <domain> [you@email]
# Or by hand: copy to targets/<domain>.conf, fill <DOMAIN>, then: angie-enable <domain>
server {
listen 80;
listen [::]:80;
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name <DOMAIN>;
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 <domain> [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/<domain>.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 <domain> [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" <<ACME
# managed by angie-issue — Angie built-in ACME (Let's Encrypt)
# resolver is required by the acme_client directive (http context).
resolver 1.1.1.1 8.8.8.8 valid=300s ipv6=off;
acme_client default https://acme-v02.api.letsencrypt.org/directory email=${email};
ACME
echo "wrote $acme_conf (email=${email})"
else
echo "_existing $acme_conf (shared default client)"
fi
target=/etc/angie/targets/${domain}.conf
sed -e "s|<DOMAIN>|${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 <domain> you@email"
echo " then reload picks it up; Angie renews automatically before expiry."
}