#!/bin/bash
# LiSoftware – den eigenen Server absichern (Debian, Ubuntu, Raspberry Pi OS)
#
#   curl -fsSLo haerten.sh https://lisoftware.de/sicherheit/haerten.sh
#   echo "<Prüfsumme von lisoftware.de/sicherheit>  haerten.sh" | sha256sum -c   # muss „OK“ melden
#   sudo bash haerten.sh --pruefen  # nur nachsehen, nichts ändern
#   sudo bash haerten.sh            # zeigt den Plan, fragt, sichert jede Datei vorher, dann los
#
# Optionen:  --pruefen          nur prüfen und berichten (ändert nichts)
#            --ja               ohne Rückfrage
#            --port N           zusätzlich Port N/tcp in der Firewall öffnen (mehrfach möglich)
#            --ohne SCHRITT     einen Schritt auslassen: updates, firewall, ssh, fail2ban, kernel, webserver
#
# Was es tut (angelehnt an DISA STIG, CIS Benchmark und BSI IT-Grundschutz, ohne Anspruch auf Zertifizierung):
#   1 updates    automatische Sicherheitsupdates (unattended-upgrades)
#   2 firewall   ufw: alles zu, außer SSH (begrenzt), 80 und 443 – andere offene Ports nennt der Plan, --port N lässt sie offen
#   3 ssh        kein root-Login, keine Passwörter (nur wenn ein Schlüssel hinterlegt ist!), kürzere Wartezeiten
#   4 fail2ban   wiederholte Fehlversuche aussperren
#   5 kernel     sichere Netz- und Kernel-Einstellungen (sysctl)
#   6 webserver  nginx: Versionsnummer verbergen, Sicherheits-Header als Baustein
# Jede geänderte Datei liegt vorher gesichert unter /root/lisoft-haerten-<Datum>/. Das Skript darf mehrfach laufen.
set -uo pipefail

VERSION=1.1
PRUEFEN=0; JA=0; PORTS=(); OHNE=()
while [ $# -gt 0 ]; do
    case "$1" in
        --pruefen) PRUEFEN=1 ;;
        --ja) JA=1 ;;
        --port) shift; [[ "${1:-}" =~ ^[0-9]{1,5}$ ]] || { echo "--port braucht eine Zahl"; exit 2; }; PORTS+=("$1") ;;
        --ohne) shift
            case "${1:-}" in updates|firewall|ssh|fail2ban|kernel|webserver) OHNE+=("$1") ;;
                *) echo "Unbekannter Schritt für --ohne: '${1:-}' (möglich: updates, firewall, ssh, fail2ban, kernel, webserver)"; exit 2 ;;
            esac ;;
        -h|--help) sed -n '2,21p' "$0"; exit 0 ;;
        *) echo "Unbekannte Option: $1 (Hilfe: --help)"; exit 2 ;;
    esac
    shift
done

if [ -t 1 ]; then G=$'\e[32m'; R=$'\e[31m'; Y=$'\e[33m'; B=$'\e[1m'; N=$'\e[0m'; else G=; R=; Y=; B=; N=; fi
ok()    { echo "  ${G}✔${N} $*"; }
fehlt() { echo "  ${R}✘${N} $*"; }
hinweis() { echo "  ${Y}!${N} $*"; }
titel() { echo; echo "${B}$*${N}"; }
ohne()  { local s; for s in "${OHNE[@]}"; do [ "$s" = "$1" ] && return 0; done; return 1; }

[ "$(id -u)" = 0 ] || { echo "Bitte mit sudo ausführen."; exit 1; }
. /etc/os-release 2>/dev/null || true
case "${ID:-}:${ID_LIKE:-}" in
    debian:*|ubuntu:*|raspbian:*|*:*debian*) ;;
    *) echo "Dieses Skript ist für Debian, Ubuntu und Raspberry Pi OS. Gefunden: ${PRETTY_NAME:-unbekannt}"; exit 1 ;;
esac

SICHERUNG="/root/lisoft-haerten-$(date +%Y%m%d-%H%M%S)"
GEAENDERT=()
sichern() { [ -e "$1" ] || return 0; mkdir -p "$SICHERUNG$(dirname "$1")"; cp -a "$1" "$SICHERUNG$1"; }
schreiben() {  # schreiben DATEI INHALT – nur wenn anders, mit Sicherung
    local datei="$1" inhalt="$2"
    if [ -f "$datei" ] && [ "$(cat "$datei")" = "$inhalt" ]; then return 1; fi
    sichern "$datei"; mkdir -p "$(dirname "$datei")"; printf '%s\n' "$inhalt" > "$datei"; GEAENDERT+=("$datei"); return 0
}
paket() { dpkg -s "$1" >/dev/null 2>&1; }
installieren() { paket "$1" || DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "$1" >/dev/null; }

SSH_USER="${SUDO_USER:-}"
SSH_KEY=0
if [ -n "$SSH_USER" ] && [ "$SSH_USER" != root ]; then
    HOMEDIR=$(getent passwd "$SSH_USER" | cut -d: -f6)
    [ -s "$HOMEDIR/.ssh/authorized_keys" ] && grep -qE '^(ssh-|ecdsa-|sk-)' "$HOMEDIR/.ssh/authorized_keys" && SSH_KEY=1
fi
SSH_DA=0; command -v sshd >/dev/null 2>&1 && SSH_DA=1
SSH_PORT=22
[ $SSH_DA = 1 ] && SSH_PORT=$(sshd -T 2>/dev/null | awk '/^port /{print $2; exit}'); SSH_PORT=${SSH_PORT:-22}
NGINX_DA=0; command -v nginx >/dev/null 2>&1 && NGINX_DA=1
# TCP-Ports, die nicht nur auf localhost lauschen
offene_ports() { ss -tlnH 2>/dev/null | awk '{print $4}' | grep -vE '^(127\.|\[::1\])' | sed 's/.*://' | sort -un; }

# ------------------------------------------------------------------------------------------------ prüfen
pruefen() {
    titel "Bestandsaufnahme (${PRETTY_NAME:-?})"
    if paket unattended-upgrades && grep -qs 'Unattended-Upgrade "1"' /etc/apt/apt.conf.d/20auto-upgrades; then ok "automatische Sicherheitsupdates an"
    else fehlt "automatische Sicherheitsupdates aus"; fi
    if command -v ufw >/dev/null && ufw status 2>/dev/null | grep -q "Status: active"; then ok "Firewall (ufw) aktiv"
    else fehlt "keine aktive Firewall (ufw)"; fi
    if [ $SSH_DA = 1 ]; then
        local t; t=$(sshd -T 2>/dev/null)
        echo "$t" | grep -qx "permitrootlogin no" && ok "SSH: kein root-Login" || fehlt "SSH: root darf sich anmelden"
        echo "$t" | grep -qx "passwordauthentication no" && ok "SSH: nur Schlüssel" || fehlt "SSH: Passwort-Anmeldung erlaubt"
        echo "$t" | grep -qx "x11forwarding no" && ok "SSH: X11 aus" || fehlt "SSH: X11-Weiterleitung an"
        [ $SSH_KEY = 1 ] && ok "SSH-Schlüssel für $SSH_USER hinterlegt" || hinweis "Für ${SSH_USER:-den Benutzer} ist kein SSH-Schlüssel hinterlegt – Passwörter bleiben dann erlaubt"
    else hinweis "kein SSH-Server installiert"; fi
    if command -v fail2ban-client >/dev/null && fail2ban-client ping >/dev/null 2>&1; then ok "fail2ban läuft"
    else fehlt "fail2ban läuft nicht"; fi
    [ "$(sysctl -n net.ipv4.conf.all.accept_redirects 2>/dev/null)" = 0 ] && ok "Kernel: ICMP-Umleitungen ignoriert" || fehlt "Kernel: ICMP-Umleitungen werden angenommen"
    [ "$(sysctl -n kernel.kptr_restrict 2>/dev/null)" = 2 ] && ok "Kernel: Adressen verborgen" || fehlt "Kernel: Kernel-Adressen sichtbar"
    if [ $NGINX_DA = 1 ]; then
        nginx -T 2>/dev/null | grep -qE '^\s*server_tokens\s+off' && ok "nginx verrät keine Version" || fehlt "nginx zeigt seine Versionsnummer"
    fi
    local offen; offen=$(offene_ports | tr '\n' ' ')
    hinweis "Von außen erreichbare TCP-Ports (lauschen nicht nur auf localhost): ${offen:-keine}"
    if command -v docker >/dev/null 2>&1; then
        hinweis "Docker gefunden: veröffentlichte Ports umgehen ufw – Container nur auf 127.0.0.1 binden (z. B. \"127.0.0.1:8080:80\")"
    fi
}

if [ $PRUEFEN = 1 ]; then pruefen; echo; echo "Nur geprüft – nichts geändert."; exit 0; fi

# ------------------------------------------------------------------------------------------------ Plan
pruefen
titel "Geplant"
ohne updates  || echo "  1 automatische Sicherheitsupdates einschalten"
ohne firewall || echo "  2 Firewall: eingehend alles zu außer SSH ($SSH_PORT, begrenzt), 80, 443${PORTS:+, ${PORTS[*]}}"
if ! ohne firewall; then
    zu=(); for p in $(offene_ports); do
        case " $SSH_PORT 80 443 ${PORTS[*]} " in *" $p "*) ;; *) zu+=("$p") ;; esac
    done
    [ ${#zu[@]} -gt 0 ] && hinweis "Diese Ports lauschen jetzt, sind danach aber von außen ZU: ${zu[*]} – offen lassen mit --port N"
fi
ohne ssh      || { [ $SSH_DA = 1 ] && echo "  3 SSH: kein root-Login$([ $SSH_KEY = 1 ] && echo ', nur Schlüssel'), kürzere Zeiten, X11 aus"; }
ohne fail2ban || echo "  4 fail2ban für SSH einrichten"
ohne kernel   || echo "  5 sichere Kernel-Einstellungen"
ohne webserver|| { [ $NGINX_DA = 1 ] && echo "  6 nginx: Versionsnummer verbergen, Header-Baustein anlegen"; }
echo "  Sicherung jeder geänderten Datei: $SICHERUNG"
if [ $SSH_DA = 1 ] && [ $SSH_KEY = 0 ] && ! ohne ssh; then
    hinweis "Kein SSH-Schlüssel für ${SSH_USER:-diesen Benutzer}: Passwort-Anmeldung bleibt AN, damit du dich nicht aussperrst."
fi
if [ $JA = 0 ]; then
    read -r -p "Fortfahren? [j/N] " antwort </dev/tty || antwort=n
    [[ "$antwort" =~ ^[jJyY] ]] || { echo "Abgebrochen – nichts geändert."; exit 0; }
fi

# ------------------------------------------------------------------------------------------------ 1 Updates
if ! ohne updates; then
    titel "1 · Automatische Sicherheitsupdates"
    apt-get update -qq >/dev/null 2>&1
    installieren unattended-upgrades && installieren apt-listchanges
    schreiben /etc/apt/apt.conf.d/20auto-upgrades 'APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
APT::Periodic::AutocleanInterval "7";' && ok "eingeschaltet" || ok "war schon an"
fi

# ------------------------------------------------------------------------------------------------ 2 Firewall
if ! ohne firewall; then
    titel "2 · Firewall"
    installieren ufw
    ufw --force default deny incoming >/dev/null 2>&1
    ufw --force default allow outgoing >/dev/null 2>&1
    ufw limit "$SSH_PORT"/tcp comment "SSH (begrenzt)" >/dev/null 2>&1
    ufw allow 80/tcp comment "HTTP (Zertifikat, Umleitung)" >/dev/null 2>&1
    ufw allow 443/tcp comment "HTTPS" >/dev/null 2>&1
    for p in "${PORTS[@]}"; do ufw allow "$p"/tcp >/dev/null 2>&1; done
    if ufw --force enable >/dev/null 2>&1; then ok "aktiv: SSH $SSH_PORT (begrenzt), 80, 443${PORTS:+, ${PORTS[*]}}"
    else fehlt "ufw ließ sich nicht einschalten (Container oder fehlende Kernel-Module?)"; fi
fi

# ------------------------------------------------------------------------------------------------ 3 SSH
if ! ohne ssh && [ $SSH_DA = 1 ]; then
    titel "3 · SSH"
    passwort=$([ $SSH_KEY = 1 ] && echo no || echo yes)
    datei=/etc/ssh/sshd_config.d/10-lisoft-haerten.conf
    if grep -qsE '^\s*Include\s+/etc/ssh/sshd_config\.d/' /etc/ssh/sshd_config; then
        if schreiben "$datei" "# LiSoftware haerten.sh $VERSION
PermitRootLogin no
PasswordAuthentication $passwort
KbdInteractiveAuthentication $passwort
PermitEmptyPasswords no
X11Forwarding no
MaxAuthTries 3
LoginGraceTime 30
ClientAliveInterval 300
ClientAliveCountMax 2"; then
            if sshd -t 2>/dev/null; then
                systemctl reload ssh 2>/dev/null || systemctl reload sshd 2>/dev/null || true
                ok "gesetzt (Passwort-Anmeldung: $passwort) – bestehende Verbindungen bleiben offen"
            else
                rm -f "$datei"; [ -e "$SICHERUNG$datei" ] && cp -a "$SICHERUNG$datei" "$datei"
                fehlt "sshd meldet einen Fehler – Änderung zurückgenommen"
            fi
        else ok "war schon so"; fi
    else hinweis "sshd_config bindet sshd_config.d nicht ein – bitte von Hand anpassen"; fi
fi

# ------------------------------------------------------------------------------------------------ 4 fail2ban
if ! ohne fail2ban; then
    titel "4 · fail2ban"
    installieren fail2ban
    schreiben /etc/fail2ban/jail.d/lisoft.local "[DEFAULT]
bantime  = 1h
findtime = 10m
maxretry = 5
backend  = systemd

[sshd]
enabled = true
port    = $SSH_PORT

[recidive]
enabled  = true
bantime  = 1w
findtime = 1d" >/dev/null
    if systemctl restart fail2ban 2>/dev/null && sleep 2 && fail2ban-client ping >/dev/null 2>&1; then ok "läuft (SSH, Wiederholungstäter eine Woche)"
    else fehlt "fail2ban startet nicht – 'journalctl -u fail2ban' zeigt den Grund"; fi
fi

# ------------------------------------------------------------------------------------------------ 5 Kernel
if ! ohne kernel; then
    titel "5 · Kernel"
    schreiben /etc/sysctl.d/90-lisoft-haerten.conf "# LiSoftware haerten.sh $VERSION – Netz und Kernel
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.default.accept_redirects = 0
net.ipv6.conf.all.accept_redirects = 0
net.ipv6.conf.default.accept_redirects = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.all.accept_source_route = 0
net.ipv6.conf.all.accept_source_route = 0
net.ipv4.conf.all.rp_filter = 2
net.ipv4.conf.all.log_martians = 1
net.ipv4.tcp_syncookies = 1
net.ipv4.icmp_echo_ignore_broadcasts = 1
kernel.kptr_restrict = 2
kernel.dmesg_restrict = 1
kernel.yama.ptrace_scope = 1
kernel.unprivileged_bpf_disabled = 1
net.core.bpf_jit_harden = 2
fs.protected_hardlinks = 1
fs.protected_symlinks = 1
fs.suid_dumpable = 0" >/dev/null
    sysctl -q --system >/dev/null 2>&1 && ok "gesetzt (rp_filter locker, damit Docker weiter funktioniert)" || hinweis "nicht alle Werte ließen sich setzen (Container?)"
fi

# ------------------------------------------------------------------------------------------------ 6 Webserver
if ! ohne webserver && [ $NGINX_DA = 1 ]; then
    titel "6 · nginx"
    tokens=/etc/nginx/conf.d/lisoft-server-tokens.conf
    if nginx -T 2>/dev/null | grep -qE '^\s*server_tokens\s+off'; then ok "Versionsnummer war schon verborgen"
    elif schreiben "$tokens" "server_tokens off;"; then
        if nginx -t >/dev/null 2>&1; then systemctl reload nginx; ok "Versionsnummer verborgen"
        else rm -f "$tokens"; fehlt "nginx -t meldet einen Fehler – zurückgenommen"; fi
    fi
    schreiben /etc/nginx/snippets/lisoft-sicherheit.conf '# LiSoftware: Sicherheits-Header – in jeden server { … } mit HTTPS: include snippets/lisoft-sicherheit.conf;
# Achtung: Ein add_header in einem location-Block hebt diese Header dort auf – dann den include auch dort setzen.
# Content-Security-Policy "default-src self" sperrt Inline-Skripte und -Stile und fremde Quellen: erst testen, dann anpassen.
# HSTS mit "includeSubDomains" erst ergänzen, wenn ALLE Subdomains HTTPS haben.
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Content-Security-Policy "default-src '\''self'\''; object-src '\''none'\''; base-uri '\''none'\''; frame-ancestors '\''none'\''" always;' >/dev/null
    hinweis "Baustein angelegt: include snippets/lisoft-sicherheit.conf; in deine HTTPS-server-Blöcke setzen, dann 'nginx -t'"
fi

# ------------------------------------------------------------------------------------------------ Ende
titel "Fertig"
if [ ${#GEAENDERT[@]} -gt 0 ]; then
    echo "  Geändert:"; printf '    %s\n' "${GEAENDERT[@]}"
    echo "  Sicherung: $SICHERUNG"
else echo "  Es war schon alles so – nichts geändert."; fi
echo "  Tipp: Öffne jetzt ein ZWEITES Terminal und melde dich neu per SSH an, bevor du dieses schließt."
