#!/usr/bin/env bash # Developed by Enjyn Gruppe — https://enjyn.de # Wachhund-Agent. Verbindet sich nur ausgehend per HTTPS zur Zentrale. # Wichtig zum Vertrauensmodell: Baseline-Härtung und Store-Tools werden NUR aus einer # festen, hier einprogrammierten Liste angewandt. Die Zentrale schickt lediglich # Schalter-Zustände (welche Schlüssel aktiv sind), niemals ausführbare Befehle. set -u VERSION=2.2.24 API_URL="${WACHHUND_API:-https://wachhund.io/api}" CONF=/etc/wachhund/agent.env STATE=/var/lib/wachhund [ -r "$CONF" ] && . "$CONF" # Öffentlicher ed25519-Schlüssel zur Prüfung signierter Agent-Updates. Der private # Schlüssel liegt offline beim Betreiber, nie auf dem Server — so kann selbst ein # kompromittierter Webserver kein gültig signiertes Update ausliefern. AGENT_PUBKEY='-----BEGIN PUBLIC KEY----- MCowBQYDK2VwAyEAYVaeS1du6zgaPSSoc1VoEqwgWTVCD+q5xxp0/woLu/8= -----END PUBLIC KEY-----' die() { echo "Fehler: $1" >&2; exit 1; } have() { command -v "$1" >/dev/null 2>&1; } mkdir -p "$STATE" 2>/dev/null # Der laufende Code ist maßgeblich: die gemerkte Version an die tatsächliche angleichen, # damit Dashboard-Anzeige und Update-Vergleich nach einem manuellen Deploy nicht veralten. [ "$(cat "$STATE/version" 2>/dev/null)" = "$VERSION" ] || printf '%s\n' "$VERSION" > "$STATE/version" 2>/dev/null # --- Datensammlung ----------------------------------------------------------- metrics() { read -r _ a b c d e f g _ < /proc/stat local idle1=$((d+e)) tot1=$((a+b+c+d+e+f+g)) sleep 1 read -r _ a b c d e f g _ < /proc/stat local idle2=$((d+e)) tot2=$((a+b+c+d+e+f+g)) local dt=$((tot2-tot1)) di=$((idle2-idle1)) CPU=0; [ "$dt" -gt 0 ] && CPU=$(( (100*(dt-di)) / dt )) MEM_TOTAL=$(awk '/MemTotal/{print $2*1024}' /proc/meminfo) local avail; avail=$(awk '/MemAvailable/{print $2*1024}' /proc/meminfo) MEM_USED=$((MEM_TOTAL-avail)) local df; df=$(df -kP / | awk 'NR==2{print $2, $3}') DISK_TOTAL=$(( $(echo "$df" | cut -d' ' -f1) * 1024 )) DISK_USED=$(( $(echo "$df" | cut -d' ' -f2) * 1024 )) } add() { printf '%s\t%s\t%s\t%s\t%s\n' "$1" "$2" "$3" "$4" "${5:-}" >> "$FIND"; } # Meldung aus Funktionen NACH dem Senden: in dauerhaften Puffer, wird beim nächsten Check-in gesendet. addp() { printf '%s\t%s\t%s\t%s\t%s\n' "$1" "$2" "$3" "$4" "${5:-}" >> "$STATE/pending"; } net() { printf '%s\t%s\t%s\n' "$1" "$2" "$3" >> "$NET"; } logn() { printf '%s\t%s\n' "$1" "$(echo "$2" | tr -s ' ' | sed 's/[[:space:]]*$//')" >> "$LOGIN"; } tstat(){ printf '%s\t%s\t%s\t%s\t%s\n' "$1" "$2" "$3" "$4" "$(printf '%s' "${5:-}" | tr '\n\t' ' ')" >> "$STATE/toolstat"; } baseline_get() { cat "$STATE/$1" 2>/dev/null; } baseline_set() { printf '%s\n' "$2" > "$STATE/$1"; } # --- Login-Verlauf ----------------------------------------------------------- collect_logins() { if have last; then last -aw 2>/dev/null | grep -vE '(^reboot | begins |^$)' | head -8 \ | while IFS= read -r l; do [ -n "$l" ] && logn erfolg "$l"; done fi if have lastb; then lastb -aw 2>/dev/null | grep -vE '( begins |^$)' | head -8 \ | while IFS= read -r l; do [ -n "$l" ] && logn fehlversuch "$l"; done fi if have who; then who 2>/dev/null | while IFS= read -r l; do [ -n "$l" ] && logn sitzung "$l"; done fi } # --- Prüfungen --------------------------------------------------------------- # Ablaufende TLS-Zertifikate. Geprüft werden Let's-Encrypt-Verzeichnisse und die in nginx/Apache # eingetragenen Zertifikatsdateien — nicht /etc/ssl/certs, das enthält nur die CA-Sammlung. certs() { have openssl || return 0 local liste="" d f for d in /etc/letsencrypt/live/*/; do [ -d "$d" ] || continue f="$d/cert.pem"; [ -f "$f" ] || f="$d/fullchain.pem" [ -f "$f" ] && liste="$liste $f" done if [ -d /etc/nginx ]; then liste="$liste $(grep -rhE '^[[:space:]]*ssl_certificate[[:space:]]' /etc/nginx/ 2>/dev/null | awk '{print $2}' | tr -d ';' | sort -u | tr '\n' ' ')" fi if [ -d /etc/apache2 ]; then liste="$liste $(grep -rhE '^[[:space:]]*SSLCertificateFile[[:space:]]' /etc/apache2/ 2>/dev/null | awk '{print $2}' | tr -d '"' | sort -u | tr '\n' ' ')" fi local gesehen="" ende ende_ts tage name jetzt jetzt=$(date +%s) for f in $(printf '%s\n' $liste | sort -u); do [ -f "$f" ] || continue case " $gesehen " in *" $f "*) continue ;; esac # Debians Platzhalter-Zertifikat steht oft im Standard-Server-Block, wird aber nie # wirklich ausgeliefert — ein Ablauf wäre nur ein Fehlalarm. case "$f" in *snakeoil*) continue ;; esac gesehen="$gesehen $f" ende=$(LC_ALL=C openssl x509 -enddate -noout -in "$f" 2>/dev/null | cut -d= -f2-) [ -n "$ende" ] || continue ende_ts=$(LC_ALL=C date -d "$ende" +%s 2>/dev/null) || continue tage=$(( (ende_ts - jetzt) / 86400 )) # Sprechender Name: bei Let's Encrypt der Ordner, sonst der erste Domainname im Zertifikat. case "$f" in /etc/letsencrypt/live/*) name=$(basename "$(dirname "$f")") ;; *) name=$(LC_ALL=C openssl x509 -subject -noout -in "$f" 2>/dev/null | sed 's/.*CN[[:space:]]*=[[:space:]]*//; s/[,/].*//') [ -n "$name" ] || name=$(basename "$f") ;; esac if [ "$tage" -lt 0 ]; then add zertifikate critical "TLS-Zertifikat für $name ist abgelaufen" "Zertifikats-Wache" \ "Abgelaufen seit $(( -tage )) Tag(en). Datei: $f — Besucher sehen eine Sicherheitswarnung." elif [ "$tage" -le 7 ]; then add zertifikate critical "TLS-Zertifikat für $name läuft in $tage Tag(en) ab" "Zertifikats-Wache" \ "Gültig bis $ende. Datei: $f — bitte zeitnah erneuern (bei Let's Encrypt prüfen, ob die automatische Erneuerung läuft)." elif [ "$tage" -le 30 ]; then add zertifikate warn "TLS-Zertifikat für $name läuft in $tage Tagen ab" "Zertifikats-Wache" \ "Gültig bis $ende. Datei: $f" fi done } # Festplatten-Gesundheit über SMART. Ohne smartmontools oder auf virtuellen Platten ohne # SMART-Unterstützung passiert nichts — dort gibt es schlicht nichts zu messen. disks() { have smartctl || return 0 local dev out health name attr for dev in /dev/sd? /dev/nvme?n?; do [ -e "$dev" ] || continue out=$(LC_ALL=C smartctl -H -A "$dev" 2>/dev/null) printf '%s' "$out" | grep -qiE 'SMART support is:[[:space:]]*Unavailable|Unable to detect device type|Operation not supported' && continue [ -n "$out" ] || continue name=$(basename "$dev") health=$(printf '%s\n' "$out" | grep -iE 'overall-health self-assessment test result|SMART Health Status' | head -1) if printf '%s' "$health" | grep -qiE 'FAILED|FAILING'; then add gesundheit critical "Festplatte $name meldet drohenden Ausfall" "Platten-Wache" \ "SMART-Gesamtbewertung: FAILED. Daten sichern und die Platte zeitnah tauschen." continue fi printf '%s' "$health" | grep -qiE 'PASSED|OK' || continue # Frühwarnzeichen: umgelagerte und wartende Sektoren sind der klassische Vorbote. for attr in Reallocated_Sector_Ct Current_Pending_Sector Offline_Uncorrectable; do local wert wert=$(printf '%s\n' "$out" | awk -v a="$attr" '$2==a{print $10}' | head -1) case "$wert" in ''|*[!0-9]*) continue ;; esac [ "$wert" -gt 0 ] && add gesundheit warn "Festplatte $name zeigt erste Ausfallzeichen" "Platten-Wache" \ "$attr steht auf $wert (normal ist 0). Noch kein Notfall, aber die Platte im Auge behalten und Sicherungen prüfen." done # NVMe: Abnutzungsgrad und Medienfehler local used med used=$(printf '%s\n' "$out" | awk -F: '/Percentage Used/{gsub(/[^0-9]/,"",$2); print $2}' | head -1) med=$(printf '%s\n' "$out" | awk -F: '/Media and Data Integrity Errors/{gsub(/[^0-9]/,"",$2); print $2}' | head -1) case "$used" in ''|*[!0-9]*) : ;; *) [ "$used" -ge 90 ] && add gesundheit warn "SSD $name ist zu $used % abgenutzt" "Platten-Wache" "Der Hersteller rechnet ab 100 % mit dem Lebensende. Ersatz einplanen." ;; esac case "$med" in ''|*[!0-9]*) : ;; *) [ "$med" -gt 0 ] && add gesundheit warn "SSD $name meldet $med Medienfehler" "Platten-Wache" "Datenintegritätsfehler treten auf. Sicherungen prüfen." ;; esac done } # Dienste, die nicht mehr laufen. Jeder betroffene Dienst wird einzeln gemeldet, damit man # sofort sieht, welcher es ist. services() { have systemctl || return 0 local svc grund kurz for svc in $(systemctl list-units --type=service --state=failed --no-legend --plain 2>/dev/null | awk '{print $1}'); do kurz=${svc%.service} grund=$(systemctl show -p Result --value "$svc" 2>/dev/null) case "$kurz" in nginx|apache2|httpd|mysql|mariadb|postgresql|docker|containerd|ssh|sshd|php*-fpm|redis*|postfix|dovecot|named|bind9) add dienste critical "Dienst $kurz läuft nicht mehr" "Dienste-Wache" \ "systemd meldet den Dienst als fehlgeschlagen${grund:+ (Grund: $grund)}. Das ist ein zentraler Dienst — vermutlich ist die von ihm bereitgestellte Anwendung gerade nicht erreichbar." ;; *) add dienste warn "Dienst $kurz ist fehlgeschlagen" "Dienste-Wache" \ "systemd meldet den Dienst als fehlgeschlagen${grund:+ (Grund: $grund)}. Mit „systemctl status $kurz“ siehst du auf dem Server den genauen Hintergrund." ;; esac done } # Persistenz-Orte: hier verankert sich ein Angreifer, damit er einen Neustart überlebt. # Beobachtet werden Einzeldateien statt nur /etc/crontab — die persönlichen Crontabs unter # /var/spool/cron sind der klassische Ort, und der lag bisher im toten Winkel. persistenz() { local first="$1" jetzt="" f h u alt zeile datei for f in /etc/cron.d/* /etc/cron.hourly/* /etc/cron.daily/* /etc/cron.weekly/* /etc/cron.monthly/* \ /var/spool/cron/crontabs/* /var/spool/cron/* /etc/sudoers.d/*; do [ -f "$f" ] || continue h=$(sha256sum "$f" 2>/dev/null | cut -d' ' -f1) || continue jetzt="$jetzt$f $h " done if have systemctl; then for u in $(systemctl list-unit-files --type=timer --state=enabled --no-legend --plain 2>/dev/null | awk '{print $1}'); do jetzt="$jetzt timer:$u aktiv " done fi alt=$(baseline_get persistenz) if [ "$first" = 0 ] && [ -n "$alt" ]; then printf '%s' "$jetzt" | while IFS= read -r zeile; do [ -n "$zeile" ] || continue datei=${zeile%% *} case "$alt" in *"$zeile"*) continue ;; # unverändert esac case "$alt" in *"$datei "*) case "$datei" in /etc/sudoers.d/*) add konten critical "Sudo-Rechte wurden geändert: $(basename "$datei")" "Persistenz-Wache" \ "Die Datei $datei hat einen anderen Inhalt als beim letzten Einlernen. Über sudoers.d verschafft man sich dauerhaft erweiterte Rechte — bitte prüfen, ob die Änderung von dir stammt." ;; timer:*) add dienste warn "Zeitgesteuerte Einheit geändert: ${datei#timer:}" "Persistenz-Wache" "Ein systemd-Timer wurde verändert." ;; *) add einbruch critical "Geplante Aufgabe wurde geändert: $(basename "$datei")" "Persistenz-Wache" \ "Der Inhalt von $datei hat sich geändert. Geplante Aufgaben sind der häufigste Weg, sich dauerhaft auf einem Server einzunisten — bitte nachsehen, was dort jetzt ausgeführt wird." ;; esac ;; *) case "$datei" in /var/spool/cron/*) add einbruch critical "Neue persönliche Aufgabenliste für Benutzer $(basename "$datei")" "Persistenz-Wache" \ "Unter $datei ist eine Crontab aufgetaucht, die es beim letzten Einlernen nicht gab. Genau so richtet sich ein Angreifer einen automatischen Wiedereinstieg ein." ;; /etc/sudoers.d/*) add konten critical "Neue Sudo-Regel angelegt: $(basename "$datei")" "Persistenz-Wache" \ "In $datei wurden erweiterte Rechte vergeben, die vorher nicht da waren." ;; timer:*) add dienste warn "Neue zeitgesteuerte Einheit: ${datei#timer:}" "Persistenz-Wache" "Ein systemd-Timer wurde neu aktiviert." ;; *) add einbruch critical "Neue geplante Aufgabe: $(basename "$datei")" "Persistenz-Wache" \ "Die Datei $datei ist neu. Bitte prüfen, was dort automatisch ausgeführt wird." ;; esac ;; esac done fi baseline_set persistenz "$jetzt" } # Speicherplatz, Inodes und Arbeitsspeicher. Eine volle Platte ist der häufigste Grund, # warum ein Server real ausfällt — gemessen wurde das bisher, gewarnt hat aber niemand. kapazitaet() { local zeile mp benutzt frei_kb jetzt_ts alt rate tage jetzt_ts=$(date +%s) LC_ALL=C df -P -x tmpfs -x devtmpfs -x squashfs -x overlay 2>/dev/null | awk 'NR>1' | while read -r _fs blocks used _avail cap mp; do benutzt=${cap%\%} case "$benutzt" in ''|*[!0-9]*) continue ;; esac case "$mp" in /snap/*|/var/lib/docker/*) continue ;; esac if [ "$benutzt" -ge 95 ]; then add gesundheit critical "Festplatte unter $mp ist zu $benutzt % voll" "Kapazitäts-Wache" \ "Nur noch $(( _avail / 1024 )) MB frei. Wird sie ganz voll, nehmen Datenbank und Webserver keine Schreibvorgänge mehr an und der Dienst fällt aus. Bitte aufräumen (alte Protokolle, Zwischenspeicher, Sicherungen)." elif [ "$benutzt" -ge 85 ]; then add gesundheit warn "Festplatte unter $mp ist zu $benutzt % voll" "Kapazitäts-Wache" \ "Noch $(( _avail / 1024 )) MB frei. Zeit, an Aufräumen zu denken, bevor es eng wird." fi # Grobe Hochrechnung aus dem letzten Messpunkt: nur melden, wenn es wirklich knapp wird. local key="kap_$(printf '%s' "$mp" | tr -c 'a-zA-Z0-9' '_')" alt=$(baseline_get "$key") baseline_set "$key" "$jetzt_ts $used" if [ -n "$alt" ] && [ "$benutzt" -ge 70 ]; then local a_ts a_used dt dused a_ts=${alt%% *}; a_used=${alt##* } dt=$(( jetzt_ts - a_ts )); dused=$(( used - a_used )) if [ "$dt" -ge 3600 ] && [ "$dused" -gt 0 ]; then rate=$(( dused * 86400 / dt )) [ "$rate" -gt 0 ] && tage=$(( _avail / rate )) if [ -n "$tage" ] && [ "$tage" -le 14 ] && [ "$tage" -ge 0 ]; then add gesundheit warn "Festplatte unter $mp ist bei diesem Verlauf in etwa $tage Tagen voll" "Kapazitäts-Wache" \ "Der belegte Platz wächst derzeit um rund $(( rate / 1024 )) MB pro Tag. Rechtzeitig aufräumen erspart einen Ausfall." fi fi fi done # Inodes: die Platte kann Platz haben und trotzdem keine Datei mehr aufnehmen. LC_ALL=C df -Pi -x tmpfs -x devtmpfs -x squashfs -x overlay 2>/dev/null | awk 'NR>1' | while read -r _fs _i _iu _if icap mp; do benutzt=${icap%\%} case "$benutzt" in ''|*[!0-9]*) continue ;; esac case "$mp" in /snap/*) continue ;; esac [ "$benutzt" -ge 90 ] && add gesundheit critical "Dateizähler unter $mp zu $benutzt % erschöpft" "Kapazitäts-Wache" \ "Es sind fast alle Inodes belegt. Dann lassen sich keine neuen Dateien mehr anlegen, obwohl noch Speicherplatz frei ist — meist verursacht durch sehr viele kleine Dateien (Sitzungen, Zwischenspeicher, Mail-Warteschlangen)." done # Arbeitsspeicher: erst zusammen mit spürbarem Auslagern ist es wirklich ein Problem. if [ -r /proc/meminfo ]; then local mt ma st sf mpct spct mt=$(awk '/^MemTotal:/{print $2}' /proc/meminfo) ma=$(awk '/^MemAvailable:/{print $2}' /proc/meminfo) st=$(awk '/^SwapTotal:/{print $2}' /proc/meminfo) sf=$(awk '/^SwapFree:/{print $2}' /proc/meminfo) if [ -n "$mt" ] && [ "$mt" -gt 0 ] && [ -n "$ma" ]; then mpct=$(( (mt - ma) * 100 / mt )) spct=0 [ -n "$st" ] && [ "$st" -gt 0 ] && spct=$(( (st - sf) * 100 / st )) if [ "$mpct" -ge 92 ] && [ "$spct" -ge 50 ]; then add gesundheit warn "Arbeitsspeicher ist zu $mpct % belegt und der Auslagerungsspeicher zu $spct %" "Kapazitäts-Wache" \ "Der Server lagert spürbar auf die Festplatte aus, das bremst alles deutlich. Entweder braucht er mehr Arbeitsspeicher oder ein Programm verbraucht mehr als vorgesehen." fi fi fi } # Programme mit gesetztem Setuid-/Setgid-Bit laufen mit fremden Rechten. Ein neues davon # an ungewöhnlicher Stelle ist eines der deutlichsten Zeichen für eine Rechteausweitung. suidwache() { local first="$1" jetzt alt neu jetzt=$(find / -xdev \( -path /proc -o -path /sys -o -path /var/lib/docker \) -prune -o \ -type f \( -perm -4000 -o -perm -2000 \) -print 2>/dev/null | sort) alt=$(baseline_get suid) if [ "$first" = 0 ] && [ -n "$alt" ]; then neu=$(printf '%s\n' "$jetzt" | grep -Fxv -f <(printf '%s\n' "$alt") 2>/dev/null) local d for d in $neu; do case "$d" in /tmp/*|/var/tmp/*|/dev/shm/*|/home/*|/var/www/*) add einbruch critical "Neues Programm mit Sonderrechten an ungewöhnlicher Stelle: $d" "Rechte-Wache" \ "Diese Datei darf mit fremden Rechten laufen und liegt an einem Ort, an dem so etwas nichts zu suchen hat. Das ist ein typisches Werkzeug, um sich Root-Rechte zu sichern." ;; *) add einbruch warn "Neues Programm mit Sonderrechten: $d" "Rechte-Wache" \ "Diese Datei darf seit dem letzten Einlernen mit fremden Rechten (Setuid/Setgid) laufen. Stammt das von einer Installation, kannst du den Normalzustand neu einlernen." ;; esac done fi baseline_set suid "$jetzt" } # Wer sich Zugang verschafft hat, löscht als Nächstes seine Spuren. Schrumpfende # Anmeldeprotokolle sind dafür das deutlichste Zeichen — die reguläre Rotation wird # dabei bewusst ausgenommen, sonst gäbe es jeden Monat einen Fehlalarm. spurenwache() { local first="$1" f name gr alt rot for f in /var/log/wtmp /var/log/btmp /var/log/lastlog; do [ -f "$f" ] || continue name=$(basename "$f") gr=$(stat -c %s "$f" 2>/dev/null) || continue alt=$(baseline_get "logsize_$name") if [ "$first" = 0 ] && [ -n "$alt" ] && [ "$gr" -lt "$alt" ]; then rot=0 # Frisch rotiert? Dann liegt die alte Datei daneben und ist gerade erst entstanden. [ -f "$f.1" ] && [ -n "$(find "$f.1" -mmin -1500 2>/dev/null)" ] && rot=1 [ -f "$f.1.gz" ] && [ -n "$(find "$f.1.gz" -mmin -1500 2>/dev/null)" ] && rot=1 if [ "$rot" = 0 ]; then add einbruch critical "Anmeldeprotokoll $name wurde verkleinert" "Spuren-Wache" \ "Die Datei $f ist kleiner geworden, ohne dass die reguläre Rotation gelaufen ist (vorher $alt Bytes, jetzt $gr). Das Löschen von Anmeldespuren ist ein starkes Anzeichen dafür, dass jemand seine Anwesenheit verbergen will." fi fi baseline_set "logsize_$name" "$gr" done } # Rootkits klinken sich gern als Kernmodul ein. kernmodule() { local first="$1" jetzt alt m have lsmod || return 0 jetzt=$(lsmod 2>/dev/null | awk 'NR>1{print $1}' | sort | tr '\n' ' ') alt=$(baseline_get kmods) if [ "$first" = 0 ] && [ -n "$alt" ]; then for m in $jetzt; do case " $alt " in *" $m "*) : ;; *) add einbruch warn "Neues Kernmodul geladen: $m" "Kern-Wache" \ "Seit dem letzten Einlernen wurde das Modul „$m“ in den Systemkern geladen. Nach einem Kernel- oder Treiber-Update ist das normal; passiert es ohne erkennbaren Anlass, kann sich damit Schadsoftware tief im System verstecken." ;; esac done fi baseline_set kmods "$jetzt" } # Betriebssystem ohne Sicherheitsunterstützung. Die Termine stehen fest im Agenten, damit # die Prüfung ohne Internetzugriff auskommt — bei neuen Veröffentlichungen hier ergänzen. lebensende() { [ -r /etc/os-release ] || return 0 local id ver eol heute rest name id=$(. /etc/os-release 2>/dev/null; printf '%s' "${ID:-}") ver=$(. /etc/os-release 2>/dev/null; printf '%s' "${VERSION_ID:-}") name=$(. /etc/os-release 2>/dev/null; printf '%s' "${PRETTY_NAME:-$ID $VERSION_ID}") [ -n "$id" ] && [ -n "$ver" ] || return 0 case "$id-$ver" in debian-9) eol=2022-06-30 ;; debian-10) eol=2024-06-30 ;; debian-11) eol=2026-08-31 ;; debian-12) eol=2028-06-30 ;; debian-13) eol=2030-06-30 ;; ubuntu-16.04) eol=2021-04-30 ;; ubuntu-18.04) eol=2023-05-31 ;; ubuntu-20.04) eol=2025-05-31 ;; ubuntu-22.04) eol=2027-06-01 ;; ubuntu-24.04) eol=2029-06-01 ;; centos-7) eol=2024-06-30 ;; centos-8) eol=2021-12-31 ;; rocky-8|almalinux-8) eol=2029-05-31 ;; rocky-9|almalinux-9) eol=2032-05-31 ;; *) return 0 ;; esac heute=$(date +%s) local eol_ts; eol_ts=$(LC_ALL=C date -d "$eol" +%s 2>/dev/null) || return 0 rest=$(( (eol_ts - heute) / 86400 )) if [ "$rest" -lt 0 ]; then EOL_ST=2 add updates critical "Betriebssystem bekommt keine Sicherheitsupdates mehr" "Lebenszyklus" \ "$name wird seit $(( -rest )) Tagen nicht mehr mit Sicherheitsupdates versorgt. Neu entdeckte Lücken werden hier nicht mehr geschlossen — ein Upgrade auf eine aktuelle Version ist der einzige wirksame Schutz." elif [ "$rest" -le 120 ]; then EOL_ST=1 add updates warn "Betriebssystem erreicht in $rest Tagen sein Lebensende" "Lebenszyklus" \ "Für $name endet die Sicherheitsunterstützung am $eol. Danach gibt es keine Sicherheitsupdates mehr. Am besten das Upgrade jetzt einplanen, solange kein Zeitdruck herrscht." fi } # Ein Prozess, der über Stunden fast die volle Rechenleistung zieht, ist das typische # Bild eines eingeschleusten Krypto-Miners. dauerlast() { have ps || return 0 local zeile pid cpu sek befehl LC_ALL=C ps -eo pid=,pcpu=,etimes=,comm= --sort=-pcpu 2>/dev/null | head -5 | while read -r pid cpu sek befehl; do case "$befehl" in xmrig|minerd|cpuminer*|cryptonight*|nicehash*|ethminer|phoenixminer|t-rex|lolminer) add prozesse critical "Bekanntes Schürf-Programm läuft: $befehl" "Last-Wache" \ "Der Prozess „$befehl“ (Nummer $pid) ist als Krypto-Miner bekannt. Solche Programme werden nach einem Einbruch installiert, um auf fremde Kosten zu rechnen." continue ;; esac cpu=${cpu%%.*} case "$cpu" in ''|*[!0-9]*) continue ;; esac case "$sek" in ''|*[!0-9]*) continue ;; esac if [ "$cpu" -ge 80 ] && [ "$sek" -ge 7200 ]; then add prozesse warn "Prozess $befehl läuft seit Stunden unter Volllast" "Last-Wache" \ "„$befehl“ (Nummer $pid) beansprucht seit $(( sek / 3600 )) Stunden im Schnitt $cpu % Rechenleistung. Bei einem Video-Encoder oder einer Datenbank kann das normal sein — kennst du das Programm nicht, ist es oft ein heimlicher Krypto-Miner." fi done } # Härtung für Webserver: die Fehler, die Webseiten am häufigsten angreifbar machen. webhaertung() { local d ini v # Quellcode-Verwaltung im Web-Verzeichnis: liegt .git öffentlich, ist der komplette # Quelltext samt Zugangsdaten abrufbar — einer der häufigsten schweren Fehler überhaupt. for d in /var/www/*/ /var/www/html/ /srv/www/*/ /usr/share/nginx/html/; do [ -d "$d" ] || continue [ -d "$d.git" ] && add dienste critical "Quellcode-Verwaltung im Web-Verzeichnis: ${d}.git" "Web-Härtung" \ "Das Verzeichnis .git liegt im öffentlich ausgelieferten Bereich. Wer die Adresse errät, kann den gesamten Quelltext samt Passwörtern und Schlüsseln herunterladen. Entweder verschieben oder im Webserver sperren." [ -f "$d.env" ] && add dienste critical "Konfigurationsdatei mit Zugangsdaten im Web-Verzeichnis: ${d}.env" "Web-Härtung" \ "Die Datei .env enthält üblicherweise Datenbank-Passwörter und Schlüssel und liegt hier im öffentlich ausgelieferten Bereich." done # Weltweit beschreibbare Verzeichnisse: jeder Nutzer auf dem System kann dort Dateien ablegen. if [ -d /var/www ]; then local ww; ww=$(find /var/www -xdev -type d -perm -o+w ! -perm -1000 2>/dev/null | head -3) [ -n "$ww" ] && add dienste warn "Für alle beschreibbare Verzeichnisse im Web-Bereich" "Web-Härtung" \ "Diese Verzeichnisse dürfen von jedem Systemnutzer beschrieben werden: $(printf '%s' "$ww" | tr '\n' ' '). Damit kann ein anderer Dienst dort Schadcode ablegen. Empfohlen sind Rechte 755 mit passendem Eigentümer." fi # PHP: sichtbare Fehlermeldungen verraten Pfade und Interna. for ini in /etc/php/*/fpm/php.ini /etc/php/*/apache2/php.ini /etc/php.ini; do [ -f "$ini" ] || continue grep -qiE '^[[:space:]]*display_errors[[:space:]]*=[[:space:]]*(on|1|true)' "$ini" 2>/dev/null \ && add dienste warn "PHP zeigt Fehlermeldungen im Browser an" "Web-Härtung" \ "In $ini steht display_errors auf An. Fehlermeldungen verraten Dateipfade, Datenbanknamen und manchmal Zugangsdaten. Auf einem öffentlich erreichbaren Server sollte das ausgeschaltet sein." done # PHP-Version ohne Sicherheitsunterstützung if have php; then v=$(LC_ALL=C php -r 'echo PHP_MAJOR_VERSION.".".PHP_MINOR_VERSION;' 2>/dev/null) case "$v" in 5.*|7.0|7.1|7.2|7.3|7.4|8.0|8.1) add updates warn "PHP $v bekommt keine Sicherheitsupdates mehr" "Web-Härtung" \ "Diese PHP-Version wird nicht mehr mit Sicherheitskorrekturen versorgt. Bekannte Lücken bleiben offen, auch wenn das System sonst aktuell ist. Ein Wechsel auf eine unterstützte Version ist der einzige Weg." ;; esac fi } # Ausgehende Verbindungen. Ein Einbruch fällt oft erst auf, wenn der Server selbst nach # draußen telefoniert — Fernsteuerung oder Datenabfluss. Ziel-IPs werden gegen die # AbuseTrack-Liste ab Score 60 geprüft (nur wenn das Modul eingerichtet ist), unabhängig # davon fallen dauerhafte Verbindungen auf ungewöhnliche Ports auf. ausgehend() { have ss || return 0 local jetzt zeile ziel ip port proz key bl alt # Nur wirklich ausgehende Verbindungen. Liegt der eigene Port auf der Lausch-Liste, hat # sich jemand bei uns verbunden — dann ist der auffaellige Port bloss der Absenderport # des Besuchers, und eine Meldung waere ein Fehlalarm. local lauscher lauscher=" $(LC_ALL=C ss -tlnH 2>/dev/null | awk '{print $4}' | sed 's/.*://' | sort -un | tr '\n' ' ') " jetzt=$(LC_ALL=C ss -tnpH state established 2>/dev/null | awk -v lau="$lauscher" '{ lport=$3; sub(/.*:/, "", lport); if (index(lau, " " lport " ") > 0) next; peer=$4; proc=""; if (match($0, /users:\(\("[^"]+"/)) { proc=substr($0, RSTART+9, RLENGTH-10) } print peer "|" proc }' | grep -vE '^(127\.|10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.|\[::1\]|\[fe80|\[fd)' | sort -u) # Eingehende Verbindungen bekannter Angreifer sind trotzdem interessant — nur eben als # laufender Angriff, nicht als Zeichen eines befallenen Systems. local eingehend eingehend=$(LC_ALL=C ss -tnpH state established 2>/dev/null | awk -v lau="$lauscher" '{ lport=$3; sub(/.*:/, "", lport); if (index(lau, " " lport " ") == 0) next; peer=$4; sub(/:[0-9]+$/, "", peer); gsub(/[\[\]]/, "", peer); print peer "|" lport }' | grep -vE '^(127\.|10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.|::1|fe80|fd)' | sort -u) # Blockliste ab Score 60 einmal täglich holen, solange AbuseTrack eingerichtet ist. key=$(cat "$STATE/at_key" 2>/dev/null) if [ -n "$key" ] && have curl; then local ts alter ts=$(cat "$STATE/at_bl60_ts" 2>/dev/null || echo 0) alter=$(( $(date +%s) - ts )) if [ "$alter" -ge 82800 ] || [ ! -s "$STATE/at_bl60" ]; then curl -s --max-time 25 -H "Authorization: Bearer $key" \ "https://abusetrack.io/api/v1/blocklist.txt?score=60" -o "$STATE/at_bl60.neu" 2>/dev/null \ && [ -s "$STATE/at_bl60.neu" ] \ && { mv -f "$STATE/at_bl60.neu" "$STATE/at_bl60"; date +%s > "$STATE/at_bl60_ts"; } rm -f "$STATE/at_bl60.neu" fi fi alt=$(cat "$STATE/ausgehend_vorher" 2>/dev/null) printf '%s\n' "$jetzt" > "$STATE/ausgehend_vorher" if [ -s "$STATE/at_bl60" ] && [ -n "$eingehend" ]; then printf '%s\n' "$eingehend" | while IFS='|' read -r qip lport; do [ -n "$qip" ] || continue grep -qxF "$qip" "$STATE/at_bl60" 2>/dev/null || continue add prozesse warn "Bekannter Angreifer ist mit Port $lport verbunden" "Ausgang-Wache" \ "Die Adresse $qip hat gerade eine offene Verbindung zu diesem Server (Port $lport) und ist bei AbuseTrack mit einem Bedrohungswert über 60 gemeldet. Das heisst nicht, dass der Einbruch geglückt ist — es zeigt aber, dass dein Server aktiv angegangen wird. Mit fail2ban und der AbuseTrack-Sperrliste lassen sich solche Adressen automatisch aussperren." done fi [ -n "$jetzt" ] || return 0 printf '%s\n' "$jetzt" | while IFS='|' read -r ziel proz; do [ -n "$ziel" ] || continue ip=${ziel%:*}; port=${ziel##*:} ip=$(printf '%s' "$ip" | tr -d '[]') case "$port" in ''|*[!0-9]*) continue ;; esac # Bekannter Angreifer als Gegenstelle: das ist immer dringend. if [ -s "$STATE/at_bl60" ] && grep -qxF "$ip" "$STATE/at_bl60" 2>/dev/null; then add einbruch critical "Verbindung zu bekannter Angreifer-Adresse${proz:+ durch $proz}" "Ausgang-Wache" \ "Dieser Server hält eine Verbindung zu $ip:$port. Die Adresse ist bei AbuseTrack mit einem Bedrohungswert über 60 gemeldet. Solche Verbindungen dienen typischerweise der Fernsteuerung befallener Systeme oder dem Abfluss von Daten." continue fi # Ungewöhnlicher Zielport, der schon beim vorigen Durchgang bestand: nur dann melden, # damit kurzlebige Verbindungen kein Rauschen erzeugen. case "$port" in 80|443|53|22|25|465|587|993|995|110|143|123|873|3306|5432|6379|11211|9418|21|20) continue ;; esac case "$alt" in *"$ziel|"*) : ;; *) continue ;; esac add prozesse warn "Dauerhafte Verbindung nach außen auf ungewöhnlichem Port${proz:+ durch $proz}" "Ausgang-Wache" \ "Seit mindestens zwei Durchgängen besteht eine Verbindung zu $ip:$port. Bei einem eigenen Dienst ist das normal — kennst du sie nicht, lohnt ein genauer Blick, welches Programm da nach außen spricht." done } # Läuft die Uhr falsch, brechen Zertifikatsprüfungen und Protokolle werden wertlos — # ein stiller Fehler, den kaum jemand bemerkt. zeitpruefung() { have timedatectl || return 0 local sync ntp # Kurz nach einem Neustart ist die Uhr noch nicht abgeglichen — das ist normal und wäre # ein Fehlalarm. Erst ab zehn Minuten Laufzeit urteilen. local lauf; lauf=$(cut -d. -f1 /proc/uptime 2>/dev/null) case "$lauf" in ''|*[!0-9]*) lauf=99999 ;; esac [ "$lauf" -lt 600 ] && return 0 sync=$(LC_ALL=C timedatectl show -p NTPSynchronized --value 2>/dev/null) ntp=$(LC_ALL=C timedatectl show -p NTP --value 2>/dev/null) [ "$sync" = "yes" ] && ZEIT_OK=1 if [ "$ntp" = "no" ]; then ZEIT_OK=0 add gesundheit warn "Zeitabgleich ist abgeschaltet" "Zeit-Wache" \ "Der Server gleicht seine Uhr nicht mit einem Zeitserver ab. Läuft die Uhr weg, schlagen Zertifikatsprüfungen fehl und die Zeitstempel in allen Protokollen werden unbrauchbar — im Ernstfall lässt sich dann nicht mehr nachvollziehen, wann etwas passiert ist." elif [ "$sync" = "no" ]; then ZEIT_OK=0 add gesundheit warn "Uhr ist nicht mit einem Zeitserver abgeglichen" "Zeit-Wache" \ "Der Zeitabgleich ist zwar eingeschaltet, war aber bislang nicht erfolgreich. Meist blockiert eine Firewall den Zugang zum Zeitserver (Port 123)." fi } # Reste einer Standardinstallation der Datenbank. Geprüft wird nur, wenn wir ohnehin # über den lokalen Systemzugang hereinkommen — es wird nie ein Passwort geraten. db_haertung() { have mysql || return 0 mysql --connect-timeout=3 -N -B -e "SELECT 1" >/dev/null 2>&1 || return 0 local ohne anon jeder test # Konten ohne Passwort. Socket-Anmeldung ist sicher und wird ausgenommen. ohne=$(mysql --connect-timeout=3 -N -B -e \ "SELECT CONCAT(user,'@',host) FROM mysql.user WHERE (authentication_string='' OR authentication_string IS NULL) AND plugin NOT IN ('unix_socket','auth_socket','mysql_no_login') AND account_locked='N';" 2>/dev/null | tr '\n' ' ') [ -n "$(printf '%s' "$ohne" | tr -d ' ')" ] && add konten critical "Datenbank-Konto ohne Passwort" "Datenbank-Härtung" \ "Diese Konten können sich ohne Passwort anmelden: $ohne. Wer den Datenbank-Port erreicht, hat damit vollen Zugriff auf die Daten." anon=$(mysql --connect-timeout=3 -N -B -e "SELECT COUNT(*) FROM mysql.user WHERE user='';" 2>/dev/null) case "$anon" in ''|*[!0-9]*) anon=0 ;; esac [ "$anon" -gt 0 ] && add konten critical "Anonymer Datenbank-Zugang vorhanden" "Datenbank-Härtung" \ "Es gibt $anon namenloses Konto in der Datenbank. Solche Konten stammen aus der Erstinstallation und erlauben eine Anmeldung ohne Benutzernamen. Empfohlen: mysql_secure_installation ausführen." jeder=$(mysql --connect-timeout=3 -N -B -e \ "SELECT COUNT(*) FROM mysql.user WHERE user IN ('root','admin') AND host IN ('%','0.0.0.0');" 2>/dev/null) case "$jeder" in ''|*[!0-9]*) jeder=0 ;; esac [ "$jeder" -gt 0 ] && add konten critical "Datenbank-Administrator von überall erreichbar" "Datenbank-Härtung" \ "Das Administrator-Konto darf sich von jeder beliebigen Adresse anmelden. Zusammen mit einem schwachen Passwort ist das der direkte Weg in die Daten. Besser auf localhost beschränken." test=$(mysql --connect-timeout=3 -N -B -e "SHOW DATABASES LIKE 'test';" 2>/dev/null) [ -n "$test" ] && add dienste warn "Beispiel-Datenbank „test“ ist noch vorhanden" "Datenbank-Härtung" \ "Die bei der Installation angelegte Datenbank „test“ existiert noch. Sie ist häufig für alle Konten beschreibbar und sollte entfernt werden." } checks() { local first=0; [ -f "$STATE/.initialized" ] || first=1 local uid0; uid0=$(awk -F: '$3==0{print $1}' /etc/passwd | sort | tr '\n' ' ') if [ "$first" = 0 ] && [ "$uid0" != "$(baseline_get uid0)" ]; then add konten critical "Neues Konto mit Root-Rechten gefunden" "Konten-Check" "UID-0-Konten jetzt: $uid0" fi baseline_set uid0 "$uid0" local users; users=$(cut -d: -f1 /etc/passwd | sort | tr '\n' ' ') if [ "$first" = 0 ] && [ "$users" != "$(baseline_get users)" ]; then add konten warn "Neues Benutzerkonto angelegt" "Konten-Check" fi baseline_set users "$users" if [ -f /root/.ssh/authorized_keys ]; then local sk; sk=$(sha256sum /root/.ssh/authorized_keys | cut -d' ' -f1) if [ "$first" = 0 ] && [ "$sk" != "$(baseline_get sshkeys)" ]; then add konten warn "SSH-Schlüssel für root geändert" "SSH-Baseline" fi baseline_set sshkeys "$sk" fi local p for p in /proc/[0-9]*/exe; do local tgt; tgt=$(readlink "$p" 2>/dev/null) || continue case "$tgt" in /tmp/*|/dev/shm/*|/var/tmp/*) add prozesse critical "Verdächtiger Prozess aus temporärem Verzeichnis" "Prozess-Check" "$tgt" ;; *"(deleted)") # Ein System-Dienst hält nach einem Paket-Update oft noch die alte, gelöschte # Programmdatei — das ist normal und wird vom Neustart-Hinweis abgedeckt. # Kritisch nur, wenn die Programmdatei an einem ungewöhnlichen Ort lag. case "$tgt" in /home/*|/run/*|/var/tmp/*|/dev/shm/*) add prozesse critical "Laufender Prozess ohne Programmdatei an ungewöhnlichem Ort" "Prozess-Check" "$tgt" ;; esac ;; esac done if have ss; then local ports; ports=$(ss -tlnH 2>/dev/null | awk '{print $4}' | sed 's/.*://' | sort -un | tr '\n' ' ') local known; known=$(baseline_get ports) [ -z "$(baseline_get allowed_ports)" ] && baseline_set allowed_ports "$ports" # Erlaubt-Liste fürs Inventar: beim Einlernen bzw. Erststart nach Update setzen, danach stabil if [ "$first" = 0 ] && [ -n "$known" ]; then local pp for pp in $ports; do case " $known " in *" $pp "*) : ;; *) net port warn "Neuer lauschender Port: $pp" ;; esac done fi baseline_set ports "$ports" fi # Laufende Dienste fürs Inventar einlernen (Erlaubt-Liste beim ersten Lauf, stabil bis rebaseline) if have systemctl; then local svcs; svcs=$(systemctl list-units --type=service --state=running --no-legend --plain 2>/dev/null | awk '{print $1}' | sed 's/\.service$//' | sort | tr '\n' ' ') [ -z "$(baseline_get allowed_svcs)" ] && baseline_set allowed_svcs "$svcs" fi # Netzwerksicherheit: Firewall-Status + nach außen offene sensible Dienste local fw_active=0 have ufw && ufw status 2>/dev/null | grep -qi "Status: active" && fw_active=1 [ "$fw_active" = 0 ] && have nft && nft list ruleset 2>/dev/null | grep -qiE 'policy drop|drop$|reject' && fw_active=1 [ "$fw_active" = 0 ] && have iptables && iptables -S 2>/dev/null | grep -qiE '^-P INPUT DROP|-j DROP' && fw_active=1 [ "$fw_active" = 0 ] && add dienste warn "Keine aktive Firewall erkannt — Empfehlung: Firewall-Baseline aktivieren, damit nur benötigte Ports erreichbar sind" "Netzwerksicherheit" if have ss; then local ex sens ex=$(ss -tlnH 2>/dev/null | awk '$4 ~ /(0\.0\.0\.0|\[::\]|\*):/{print $4}' | sed 's/.*://' | sort -un) for sens in $ex; do case "$sens" in 3306|5432|27017|6379|9200|11211|5984|9042) DB_OFFEN=1 add dienste warn "Datenbank-/Cache-Dienst auf Port $sens ist nach außen offen — Empfehlung: an 127.0.0.1 binden oder per Firewall abschirmen" "Netzwerksicherheit" ;; esac done fi [ -f /var/run/reboot-required ] && add updates warn "Neustart ausstehend (Kernel- oder Kernbibliotheks-Update)" "Gesundheit" if have apt-get; then local sec; sec=$(apt-get -s -o Debug::NoLocking=true upgrade 2>/dev/null | grep -c '^Inst.*security' || true) case "$sec" in ''|*[!0-9]*) sec=0 ;; esac SEC_UPD=$sec [ "$sec" -gt 0 ] && add updates warn "$sec offene Sicherheitsupdates" "Frische" fi if [ -d /etc/wachhund/checks.d ]; then local mod for mod in /etc/wachhund/checks.d/*; do [ -x "$mod" ] || continue local out rc name out=$("$mod" 2>/dev/null); rc=$? name=$(grep -m1 '^# name:' "$mod" 2>/dev/null | sed 's/^# name:[[:space:]]*//') [ -z "$name" ] && name=$(basename "$mod") case "$rc" in 1) add eigene warn "$name: ${out:-Warnung}" "Eigener Check" ;; 2) add eigene critical "$name: ${out:-Kritisch}" "Eigener Check" ;; esac done fi # Härtungs-Hinweise zu SSH-Zugang (Empfehlungen). Effektive Konfiguration prüfen, # nicht nur die Datei — Standardwerte (z. B. PasswordAuthentication) greifen auch # ohne explizite Zeile. sshd -T liefert den tatsächlich wirksamen Wert. local sshcfg="" have sshd && sshcfg=$(sshd -T 2>/dev/null) [ -z "$sshcfg" ] && sshcfg=$(grep -ihE '^[[:space:]]*(PasswordAuthentication|PermitRootLogin)[[:space:]]' /etc/ssh/sshd_config 2>/dev/null | tr 'A-Z' 'a-z') printf '%s\n' "$sshcfg" | grep -qiE '^[[:space:]]*passwordauthentication[[:space:]]+yes' \ && add konten warn "SSH-Passwort-Login ist aktiv — Empfehlung: in der Sicherheits-Baseline auf reine Schlüssel-Anmeldung umstellen" "Härtung" printf '%s\n' "$sshcfg" | grep -qiE '^[[:space:]]*permitrootlogin[[:space:]]+yes' \ && add konten warn "Direkter Root-Login per Passwort ist erlaubt — Empfehlung: auf Schlüssel beschränken (Baseline)" "Härtung" services certs persistenz "$first" kapazitaet spurenwache "$first" kernmodule "$first" lebensende dauerlast ausgehend zeitpruefung db_haertung # Teure Durchsuchungen des Dateisystems und SMART (weckt schlafende Platten) # laufen nur beim taeglichen Rundgang. if [ "${WH_DEEP:-0}" = 1 ]; then disks suidwache "$first" webhaertung fi haertungsgrad "$sshcfg" "$fw_active" touch "$STATE/.initialized" } # Härtungsgrad. Bis 2.2.23 waren es drei Kriterien — damit landeten fast alle Server auf # 90 oder 100 und der Wert sagte wenig. Jetzt elf gewichtete Kriterien, Summe genau 100, # jedes einzeln ausgewiesen: das Dashboard kann so zeigen, WAS die fehlenden Punkte kostet, # statt nur eine nackte Zahl. Schema-Nummer wird mitgesendet, damit die Zentrale nur Werte # derselben Rechenweise miteinander vergleicht. SCORE_SCHEMA=2 haertungsgrad() { local sshcfg="$1" fw="$2" local score=0 # punkt punkt() { [ "$3" = 1 ] && score=$((score + $2)) [ -n "${HRT:-}" ] && printf '%s\t%s\t%s\n' "$1" "$2" "$3" >> "$HRT" } local ok ok=1; printf '%s\n' "$sshcfg" | grep -qiE '^[[:space:]]*passwordauthentication[[:space:]]+yes' && ok=0 punkt ssh_nopass 15 "$ok" ok=1; printf '%s\n' "$sshcfg" | grep -qiE '^[[:space:]]*permitrootlogin[[:space:]]+yes' && ok=0 punkt ssh_noroot 10 "$ok" punkt firewall 13 "$fw" # Automatische Sicherheitsupdates: Debian/Ubuntu über unattended-upgrades, RHEL-Familie # über dnf-automatic. Der Timer allein genügt nicht — er muss auch aktiviert sein. ok=0 if [ -f /etc/apt/apt.conf.d/20auto-upgrades ] && grep -qE 'Unattended-Upgrade[^;]*"1"' /etc/apt/apt.conf.d/20auto-upgrades 2>/dev/null; then ok=1; fi [ "$ok" = 0 ] && have systemctl && systemctl is-enabled dnf-automatic.timer >/dev/null 2>&1 && ok=1 punkt autoupdate 10 "$ok" ok=1; [ "${SEC_UPD:-0}" -gt 0 ] && ok=0 punkt updates 12 "$ok" ok=1; [ -f /var/run/reboot-required ] && ok=0 punkt reboot 5 "$ok" ok=0 have systemctl && { systemctl is-active fail2ban >/dev/null 2>&1 || systemctl is-active sshguard >/dev/null 2>&1; } && ok=1 punkt bruteforce 10 "$ok" ok=1; [ "${DB_OFFEN:-0}" = 1 ] && ok=0 punkt dbzu 8 "$ok" # Erst das überschrittene Lebensende zählt als Mangel: solange noch Updates kommen, ist # das System härtbar. Auf das nahende Ende weist ohnehin ein eigener Befund hin. Teil- # punkte wären hier falsch — dann ginge die Rechnung „100 minus offene Punkte" nicht auf. ok=1; [ "${EOL_ST:-0}" = 2 ] && ok=0 punkt eol 8 "$ok" ok=1 [ "${DISK_TOTAL:-0}" -gt 0 ] && [ $(( 100 * ${DISK_USED:-0} / DISK_TOTAL )) -ge 90 ] && ok=0 punkt platte 5 "$ok" ok="${ZEIT_OK:-1}" punkt zeit 4 "$ok" unset -f punkt [ "$score" -lt 0 ] && score=0 [ "$score" -gt 100 ] && score=100 HARDENING=$score } # Bestandsaufnahme fürs Dashboard: aktuell lauschende Ports und laufende Dienste, jeweils mit # Marker, ob sie zum eingelernten Normalzustand gehören (1=erlaubt) oder neu sind (0). Rein # beschreibend — ändert nichts. Begrenzt, damit die Übertragung schlank bleibt. inventory() { [ -n "${INV:-}" ] || return 0 local allowed_p allowed_s allowed_p=" $(baseline_get allowed_ports) " allowed_s=" $(baseline_get allowed_svcs) " if have ss; then ss -tlnpH 2>/dev/null | while IFS= read -r line; do local addr port proc addr=$(printf '%s' "$line" | awk '{print $4}') port=${addr##*:} case "$port" in ''|*[!0-9]*) continue ;; esac proc=$(printf '%s' "$line" | grep -oE 'users:\(\("[^"]+' | sed 's/.*"//' | head -1) case "$allowed_p" in *" $port "*) printf 'port\t%s\t1\t%s\n' "$port" "${proc:-Dienst}" >> "$INV" ;; *) printf 'port\t%s\t0\t%s\n' "$port" "${proc:-Dienst}" >> "$INV" ;; esac done fi if have systemctl; then systemctl list-units --type=service --state=running --no-legend --plain 2>/dev/null \ | awk '{print $1}' | sed 's/\.service$//' | head -80 | while IFS= read -r svc; do [ -n "$svc" ] || continue case "$allowed_s" in *" $svc "*) printf 'dienst\t%s\t1\t\n' "$svc" >> "$INV" ;; *) printf 'dienst\t%s\t0\t\n' "$svc" >> "$INV" ;; esac done fi } # Verlauf beobachten (rein lesend): Änderungen an sicherheitskritischen Dateien, am Passwort- # Speicher und an SSH-Schlüsseln. Meldet NUR, DASS sich etwas geändert hat — nie Inhalte. activity_scan() { [ -n "${ACT:-}" ] || return 0 local first=0; [ -f "$STATE/.act_init" ] || first=1 if [ -f /etc/shadow ]; then local sm; sm=$(stat -c %Y /etc/shadow 2>/dev/null) if [ "$first" = 0 ] && [ -n "$sm" ] && [ "$sm" != "$(baseline_get act_shadow)" ]; then printf 'passwort\tEin Konto-Passwort wurde gesetzt oder geändert (%s)\n' "$(date -d @"$sm" '+%d.%m.%Y %H:%M' 2>/dev/null)" >> "$ACT" fi baseline_set act_shadow "$sm" fi local akhash; akhash=$( { for kf in /root/.ssh/authorized_keys /home/*/.ssh/authorized_keys; do [ -f "$kf" ] && cat "$kf"; done; } 2>/dev/null | sha256sum | cut -d' ' -f1) if [ "$first" = 0 ] && [ -n "$akhash" ] && [ "$akhash" != "$(baseline_get act_keys)" ]; then printf 'sshkey\tEin SSH-Schlüssel wurde hinterlegt oder geändert (%s)\n' "$(date '+%d.%m.%Y %H:%M')" >> "$ACT" fi baseline_set act_keys "$akhash" local f n m for f in /etc/passwd /etc/group /etc/sudoers /etc/ssh/sshd_config /etc/crontab /etc/hosts; do [ -f "$f" ] || continue n=$(printf '%s' "$f" | tr '/.' '__') m=$(stat -c %Y "$f" 2>/dev/null) if [ "$first" = 0 ] && [ -n "$m" ] && [ "$m" != "$(baseline_get "act_$n")" ]; then printf 'datei\t%s wurde bearbeitet (%s)\n' "$f" "$(date -d @"$m" '+%d.%m.%Y %H:%M' 2>/dev/null)" >> "$ACT" fi baseline_set "act_$n" "$m" done touch "$STATE/.act_init" } # --- Baseline-Härtung (fest einprogrammiert, abgesichert) -------------------- ssh_reload() { systemctl reload ssh 2>/dev/null || systemctl reload sshd 2>/dev/null || service ssh reload 2>/dev/null || true; } ssh_port() { local p; p=$(grep -iE '^\s*Port\s+[0-9]+' /etc/ssh/sshd_config 2>/dev/null | awk '{print $2}' | head -1); echo "${p:-22}"; } baseline_ssh_nopass() { grep -qiE '^\s*PasswordAuthentication\s+no' /etc/ssh/sshd_config 2>/dev/null && return 0 local haskey=0 kf for kf in /root/.ssh/authorized_keys /home/*/.ssh/authorized_keys; do [ -s "$kf" ] && haskey=1 && break done if [ "$haskey" = 0 ]; then add konten warn "SSH-Passwort-Login NICHT deaktiviert: kein Schlüssel hinterlegt (Schutz vor Aussperren)" "Baseline" return 0 fi if grep -qiE '^\s*#?\s*PasswordAuthentication' /etc/ssh/sshd_config; then sed -i 's/^[[:space:]]*#\?[[:space:]]*PasswordAuthentication.*/PasswordAuthentication no/I' /etc/ssh/sshd_config else echo 'PasswordAuthentication no' >> /etc/ssh/sshd_config fi ssh_reload add konten info "SSH-Passwort-Login durch Wachhund-Baseline deaktiviert" "Baseline" } baseline_ssh_noroot() { grep -qiE '^\s*PermitRootLogin\s+(prohibit-password|no)' /etc/ssh/sshd_config 2>/dev/null && return 0 if grep -qiE '^\s*#?\s*PermitRootLogin' /etc/ssh/sshd_config; then sed -i 's/^[[:space:]]*#\?[[:space:]]*PermitRootLogin.*/PermitRootLogin prohibit-password/I' /etc/ssh/sshd_config else echo 'PermitRootLogin prohibit-password' >> /etc/ssh/sshd_config fi ssh_reload add konten info "Direkter Root-Login durch Wachhund-Baseline auf Schlüssel beschränkt" "Baseline" } baseline_firewall() { have ufw || return 0 ufw status 2>/dev/null | grep -qi "Status: active" && return 0 local sp; sp=$(ssh_port) ufw allow "$sp"/tcp >/dev/null 2>&1 ufw --force enable >/dev/null 2>&1 add updates info "Firewall (ufw) durch Wachhund-Baseline aktiviert (SSH-Port $sp offen gehalten)" "Baseline" } baseline_autoupdates() { have apt-get || return 0 [ -f /etc/apt/apt.conf.d/20auto-upgrades ] && grep -q 'Unattended-Upgrade "1"' /etc/apt/apt.conf.d/20auto-upgrades && return 0 DEBIAN_FRONTEND=noninteractive apt-get install -y -qq unattended-upgrades >/dev/null 2>&1 printf 'APT::Periodic::Update-Package-Lists "1";\nAPT::Periodic::Unattended-Upgrade "1";\n' > /etc/apt/apt.conf.d/20auto-upgrades add updates info "Automatische Sicherheitsupdates durch Wachhund-Baseline aktiviert" "Baseline" } apply_baseline() { local flags f; flags=$(cat "$STATE/baseline" 2>/dev/null) || return 0 [ -n "$flags" ] || return 0 for f in $flags; do case "$f" in ssh_nopass) baseline_ssh_nopass ;; ssh_noroot) baseline_ssh_noroot ;; firewall) baseline_firewall ;; autoupdates) baseline_autoupdates ;; esac done } # --- Tool-Store (fest einprogrammiert) --------------------------------------- apt_install() { # Nur Pakete vormerken, die WIR neu installieren — vorher vorhandene bleiben beim Widerruf unberührt. if ! dpkg -s "$1" >/dev/null 2>&1; then grep -qxF "$1" "$STATE/installed_pkgs" 2>/dev/null || echo "$1" >> "$STATE/installed_pkgs" fi DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "$1" >/dev/null 2>&1 } throttled() { [ "${WH_DEEP:-0}" = 1 ] || return 1; local last; last=$(cat "$STATE/tool_$1_ts" 2>/dev/null || echo 0); [ $(( $(date +%s) - last )) -ge 82800 ]; } mark_run() { date +%s > "$STATE/tool_$1_ts"; } tool_fail2ban() { have fail2ban-client || apt_install fail2ban if have fail2ban-client; then # Einstellungen aus dem Panel anwenden (Fehlversuche, Sperrdauer) local mr bt mr=$(grep -m1 '^maxretry=' "$STATE/toolset_fail2ban" 2>/dev/null | cut -d= -f2) bt=$(grep -m1 '^bantime=' "$STATE/toolset_fail2ban" 2>/dev/null | cut -d= -f2) if [ -n "$mr" ] || [ -n "$bt" ]; then { echo "[sshd]"; echo "enabled = true" [ -n "$mr" ] && echo "maxretry = $mr" [ -n "$bt" ] && echo "bantime = ${bt}m"; } > /etc/fail2ban/jail.d/wachhund.local systemctl reload fail2ban >/dev/null 2>&1 || systemctl restart fail2ban >/dev/null 2>&1 fi systemctl enable --now fail2ban >/dev/null 2>&1 if systemctl is-active fail2ban >/dev/null 2>&1; then local n; n=$(fail2ban-client status sshd 2>/dev/null | grep -i 'Currently banned' | grep -oE '[0-9]+' | head -1) tstat fail2ban 1 ok "aktiv, ${n:-0} IP(s) gesperrt" else tstat fail2ban 1 warnung "installiert, aber nicht aktiv" "Der fail2ban-Dienst läuft nicht — der Brute-Force-Schutz ist derzeit unwirksam. Der Wachhund versucht ihn beim nächsten Rundgang zu starten."; fi else tstat fail2ban 0 fehler "Installation fehlgeschlagen"; fi } tool_auditd() { have auditctl || apt_install auditd if have auditctl; then systemctl enable --now auditd >/dev/null 2>&1 # kritische Pfade in Echtzeit überwachen (idempotent) auditctl -w /etc/passwd -p wa -k wh_passwd 2>/dev/null auditctl -w /etc/ssh/sshd_config -p wa -k wh_ssh 2>/dev/null auditctl -w /etc/sudoers -p wa -k wh_sudo 2>/dev/null if systemctl is-active auditd >/dev/null 2>&1; then local n; n=$(ausearch -k wh_passwd -k wh_ssh -k wh_sudo -ts today 2>/dev/null | grep -c '^type=' || true) tstat auditd 1 ok "Echtzeit-Audit aktiv, ${n:-0} Ereignis(se) heute" else tstat auditd 1 warnung "installiert, aber nicht aktiv" "Der auditd-Dienst läuft nicht — die Echtzeit-Überwachung kritischer Dateien (/etc/passwd, SSH-Konfig, sudoers) ist derzeit inaktiv."; fi else tstat auditd 0 fehler "Installation fehlgeschlagen"; fi } tool_rkhunter() { have rkhunter || apt_install rkhunter if ! have rkhunter; then tstat rkhunter 0 fehler "Installation fehlgeschlagen"; return; fi if throttled rkhunter; then rkhunter --propupd --quiet >/dev/null 2>&1 rkhunter --check --sk --nocolors --rwo >/tmp/wh_rk 2>/dev/null mark_run rkhunter local w; w=$(grep -c . /tmp/wh_rk 2>/dev/null || true) if [ "${w:-0}" -gt 0 ]; then tstat rkhunter 1 warnung "$w Warnung(en) im letzten Scan" "$(head -c 400 /tmp/wh_rk)" add einbruch warn "rkhunter meldet $w Warnung(en)" "rkhunter" "$(head -c 300 /tmp/wh_rk)" else tstat rkhunter 1 ok "keine Auffälligkeiten"; fi rm -f /tmp/wh_rk else tstat rkhunter 1 ok "installiert (Tiefenscan läuft täglich)"; fi } tool_chkrootkit() { have chkrootkit || apt_install chkrootkit if ! have chkrootkit; then tstat chkrootkit 0 fehler "Installation fehlgeschlagen"; return; fi if throttled chkrootkit; then local inf; inf=$(chkrootkit -q 2>/dev/null | grep -iE 'INFECTED' | head -3) mark_run chkrootkit if [ -n "$inf" ]; then tstat chkrootkit 1 warnung "mögliche Funde" "$inf" add einbruch critical "chkrootkit: mögliche Infektion" "chkrootkit" "$inf" else tstat chkrootkit 1 ok "keine Auffälligkeiten"; fi else tstat chkrootkit 1 ok "installiert (Scan läuft täglich)"; fi } tool_lynis() { have lynis || apt_install lynis if ! have lynis; then tstat lynis 0 fehler "Installation fehlgeschlagen"; return; fi if throttled lynis; then lynis audit system --quiet --no-colors >/dev/null 2>&1 mark_run lynis local idx; idx=$(grep -i 'hardening_index' /var/log/lynis.log 2>/dev/null | tail -1 | grep -oE '[0-9]+' | head -1) tstat lynis 1 ok "Härtungs-Index ${idx:-–}/100" "Der vollständige Lynis-Bericht mit konkreten Härtungs-Empfehlungen liegt auf dem Server unter /var/log/lynis.log (Abschnitt „Suggestions“)." else local idx; idx=$(grep -i 'hardening_index' /var/log/lynis.log 2>/dev/null | tail -1 | grep -oE '[0-9]+' | head -1) tstat lynis 1 ok "installiert${idx:+, Index $idx/100} (Audit läuft täglich)" fi } tool_clamav() { have clamscan || apt_install clamav if ! have clamscan; then tstat clamav 0 fehler "Installation fehlgeschlagen"; return; fi if throttled clamav; then have freshclam && freshclam --quiet >/dev/null 2>&1 local hits; hits=$(clamscan -r -i --no-summary /home /tmp /var/tmp 2>/dev/null | grep -c 'FOUND' || true) mark_run clamav if [ "${hits:-0}" -gt 0 ]; then tstat clamav 1 warnung "$hits Fund(e)" add einbruch critical "ClamAV: $hits Schadsoftware-Fund(e)" "ClamAV" else tstat clamav 1 ok "keine Funde"; fi else tstat clamav 1 ok "installiert (Scan läuft täglich)"; fi } tool_aide() { have aide || apt_install aide if ! have aide; then tstat aide 0 fehler "Installation fehlgeschlagen"; return; fi if [ ! -f /var/lib/aide/aide.db ]; then aideinit -y -f >/dev/null 2>&1 || aide --init >/dev/null 2>&1 [ -f /var/lib/aide/aide.db.new ] && mv /var/lib/aide/aide.db.new /var/lib/aide/aide.db tstat aide 1 ok "Integritäts-Datenbank angelegt" mark_run aide elif throttled aide; then local ch; ch=$(aide --check 2>/dev/null | grep -iE 'entries changed|added|removed' | head -1) mark_run aide if echo "$ch" | grep -qiE '[1-9]'; then tstat aide 1 warnung "Änderungen erkannt" "$ch" add einbruch warn "AIDE meldet Änderungen an Systemdateien" "AIDE" "$ch" else tstat aide 1 ok "keine Änderungen"; fi else tstat aide 1 ok "aktiv (Prüfung läuft täglich)"; fi } tool_debsums() { have debsums || apt_install debsums if ! have debsums; then tstat debsums 0 fehler "auf diesem System nicht verfügbar"; return; fi if throttled debsums; then local ch; ch=$(debsums -s 2>&1 | head -5) mark_run debsums if [ -n "$ch" ]; then tstat debsums 1 warnung "veränderte Paketdateien" "$ch" add einbruch warn "debsums: veränderte Systemdateien erkannt" "debsums" "$ch" else tstat debsums 1 ok "alle Paketdateien unverändert"; fi else tstat debsums 1 ok "aktiv (Prüfung läuft täglich)"; fi } tool_arpwatch() { have arpwatch || apt_install arpwatch if have arpwatch; then systemctl enable --now arpwatch >/dev/null 2>&1 || true tstat arpwatch 1 ok "überwacht das Netz auf ARP-Spoofing" else tstat arpwatch 0 fehler "auf diesem System nicht verfügbar"; fi } tool_psad() { have psad || apt_install psad if ! have psad; then tstat psad 0 fehler "auf diesem System nicht verfügbar"; return; fi systemctl enable --now psad >/dev/null 2>&1 || true if throttled psad; then psad --sig-update >/dev/null 2>&1 || true; mark_run psad; fi if systemctl is-active psad >/dev/null 2>&1; then local n; n=$(psad --Status 2>/dev/null | grep -iE 'scan sources' | grep -oE '[0-9]+' | head -1) if [ -n "$n" ] && [ "$n" -gt 0 ]; then local det; det=$(psad --Status 2>/dev/null | grep -iE 'src:|Source|DL:|packets|signature match' | head -12) tstat psad 1 warnung "$n Portscan-Quelle(n) erkannt" "${det:-Details siehe psad --Status auf dem Server}" add prozesse warn "psad meldet $n Portscan-Quelle(n) gegen diesen Server" "psad" "$det" else tstat psad 1 ok "überwacht auf Portscans"; fi else tstat psad 1 warnung "installiert, Dienst nicht aktiv" "Der psad-Dienst läuft nicht — die Portscan-Erkennung ist derzeit inaktiv. Der Wachhund versucht ihn beim nächsten Rundgang zu starten."; fi } tool_needrestart() { have needrestart || apt_install needrestart if ! have needrestart; then tstat needrestart 0 fehler "auf diesem System nicht verfügbar"; return; fi local svclist svc; svclist=$(needrestart -b 2>/dev/null | grep 'NEEDRESTART-SVC' | sed 's/.*:[[:space:]]*//' | sort -u | tr '\n' ' ') svc=$(printf '%s' "$svclist" | wc -w | tr -d ' ') if [ "${svc:-0}" -gt 0 ]; then tstat needrestart 1 warnung "$svc Dienst(e) brauchen Neustart" "Betroffen: $svclist" add updates warn "$svc Dienst(e) laufen mit veralteten Bibliotheken (Neustart nötig)" "needrestart" "Betroffene Dienste: $svclist" else tstat needrestart 1 ok "keine veralteten Dienste"; fi } tool_acct() { have lastcomm || apt_install acct if have lastcomm; then systemctl enable --now acct >/dev/null 2>&1 || systemctl enable --now psacct >/dev/null 2>&1 || true tstat acct 1 ok "protokolliert ausgeführte Befehle" else tstat acct 0 fehler "auf diesem System nicht verfügbar"; fi } tool_unhide() { have unhide || apt_install unhide if ! have unhide; then tstat unhide 0 fehler "auf diesem System nicht verfügbar"; return; fi if throttled unhide; then local h; h=$(unhide quick 2>/dev/null | grep -iE 'Found HIDDEN' | head -3) mark_run unhide if [ -n "$h" ]; then tstat unhide 1 warnung "versteckte Prozesse gefunden" "$h" add einbruch critical "unhide: versteckte Prozesse erkannt" "unhide" "$h" else tstat unhide 1 ok "keine versteckten Prozesse"; fi else tstat unhide 1 ok "installiert (Prüfung läuft täglich)"; fi } tool_logwatch() { have logwatch || apt_install logwatch if have logwatch; then tstat logwatch 1 ok "täglicher Log-Bericht aktiv" else tstat logwatch 0 fehler "auf diesem System nicht verfügbar"; fi } tool_apparmor() { have aa-status || apt_install apparmor have aa-status && ! have aa-enforce && apt_install apparmor-utils if have aa-status; then systemctl enable --now apparmor >/dev/null 2>&1 || true local n; n=$(aa-status --profiled 2>/dev/null | head -1 || echo 0) tstat apparmor 1 ok "${n:-0} Profil(e) aktiv" else tstat apparmor 0 fehler "auf diesem System nicht verfügbar"; fi } manage_tools() { local tools t hb; tools=$(cat "$STATE/tools" 2>/dev/null) || return 0 [ -n "$tools" ] || return 0 : > "$STATE/toolstat" rm -f "$STATE/tool_progress" # Welche aktivierten Tools sind noch nicht installiert? (Binary-Prüfung je Tool) local pending="" for t in $tools; do case "$t" in fail2ban) hb=fail2ban-client;; auditd) hb=auditctl;; rkhunter) hb=rkhunter;; chkrootkit) hb=chkrootkit;; lynis) hb=lynis;; clamav) hb=clamscan;; aide) hb=aide;; debsums) hb=debsums;; arpwatch) hb=arpwatch;; psad) hb=psad;; needrestart) hb=needrestart;; acct) hb=lastcomm;; unhide) hb=unhide;; logwatch) hb=logwatch;; apparmor) hb=aa-status;; *) continue;; esac have "$hb" || pending="$pending $t" done # Fortschritt: pro Kontakt höchstens EIN neues Tool einrichten. Mit dem Schnell-Takt läuft # der Fortschritt Schritt für Schritt im Dashboard durch, statt in einem langen Rutsch. local install_now="" remaining=0 if [ -n "$pending" ]; then install_now=$(echo $pending | awk '{print $1}') remaining=$(echo $pending | wc -w | tr -d ' ') fi for t in $tools; do declare -F "tool_$t" >/dev/null 2>&1 || continue case " $pending " in *" $t "*) if [ "$t" = "$install_now" ]; then "tool_$t"; else tstat "$t" 0 laeuft "wird demnächst eingerichtet"; fi ;; *) "tool_$t" ;; esac done [ -n "$install_now" ] && printf 'setup\t%s\t%s\n' "$install_now" "$remaining" > "$STATE/tool_progress" } # Hält die aktivierten Sicherheitstools und ihre Signatur-Datenbanken aktuell. # Läuft im täglichen Tiefenscan (nicht im Minuten-Herzschlag). update_tools() { have apt-get || return 0 local tools; tools=$(cat "$STATE/tools" 2>/dev/null); [ -n "$tools" ] || return 0 local pkgs="" t for t in $tools; do case "$t" in fail2ban) pkgs="$pkgs fail2ban";; rkhunter) pkgs="$pkgs rkhunter";; chkrootkit) pkgs="$pkgs chkrootkit";; lynis) pkgs="$pkgs lynis";; clamav) pkgs="$pkgs clamav";; auditd) pkgs="$pkgs auditd";; aide) pkgs="$pkgs aide";; debsums) pkgs="$pkgs debsums";; arpwatch) pkgs="$pkgs arpwatch";; needrestart) pkgs="$pkgs needrestart";; psad) pkgs="$pkgs psad";; acct) pkgs="$pkgs acct";; unhide) pkgs="$pkgs unhide";; logwatch) pkgs="$pkgs logwatch";; apparmor) pkgs="$pkgs apparmor";; esac done [ -n "$pkgs" ] || return 0 apt-get update -qq >/dev/null 2>&1 local up; up=$(DEBIAN_FRONTEND=noninteractive apt-get install --only-upgrade -y -qq $pkgs 2>&1 | grep -ciE '^(Setting up|Unpacking) ' || true) [ "${up:-0}" -gt 0 ] && addp updates info "$up Sicherheitstool-Aktualisierung(en) automatisch eingespielt" "Tool-Update" # Signatur-/Regel-Datenbanken frisch halten have rkhunter && rkhunter --update --quiet >/dev/null 2>&1 have freshclam && freshclam --quiet >/dev/null 2>&1 return 0 } # AbuseTrack: Angreifer-IPs melden + Community-Blocklist ziehen und sperren. # Läuft im täglichen Tiefenscan. Key/Flags kommen als Config vom Panel. abusetrack_run() { local key flags; key=$(cat "$STATE/at_key" 2>/dev/null); flags=" $(cat "$STATE/at_flags" 2>/dev/null) " [ -n "$key" ] || return 0 have curl || return 0 # 1) Melden: aktuell gesperrte IPs (fail2ban) an AbuseTrack, jede nur einmal if [ "$flags" != "${flags#* report }" ] && have fail2ban-client; then local jail ip rep=0 for jail in $(fail2ban-client status 2>/dev/null | sed -n 's/.*Jail list:[[:space:]]*//p' | tr ',' ' '); do for ip in $(fail2ban-client status "$jail" 2>/dev/null | sed -n 's/.*Banned IP list:[[:space:]]*//p'); do echo "$ip" | grep -qE '^(10\.|127\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)' && continue grep -qxF "$ip" "$STATE/at_reported" 2>/dev/null && continue curl -s -o /dev/null --max-time 8 -X POST "https://abusetrack.io/api/v1/reports" \ -H "Authorization: Bearer $key" -H "Content-Type: application/json" \ -d "{\"type\":\"ip\",\"value\":\"$ip\",\"threatType\":\"brute-force\",\"comment\":\"Wiederholte fehlgeschlagene Anmeldungen, gemeldet vom Wachhund-Agenten\"}" \ && { echo "$ip" >> "$STATE/at_reported"; rep=$((rep+1)); } done done [ "$rep" -gt 0 ] && addp prozesse info "$rep Angreifer-IP(s) an AbuseTrack gemeldet" "AbuseTrack" fi # 2) Ziehen + Bannen: Community-Blocklist per ipset/iptables (nur bei aktiver Verbindung geschützt) if [ "$flags" != "${flags#* block }" ]; then have ipset || apt_install ipset have iptables || apt_install iptables if ! have ipset || ! have iptables; then addp prozesse warn "AbuseTrack-Sperrliste: ipset/iptables nicht verfügbar" "AbuseTrack"; return 0 fi ipset create wachhund_abuse hash:ip -exist 2>/dev/null # eigene Kette: aktive Verbindungen NIE kappen (Schutz vor Selbst-Aussperren), nur neue Verbindungen sperren if ! iptables -C INPUT -j wh_abuse 2>/dev/null; then iptables -N wh_abuse 2>/dev/null iptables -F wh_abuse 2>/dev/null iptables -A wh_abuse -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN 2>/dev/null iptables -A wh_abuse -m set --match-set wachhund_abuse src -j DROP 2>/dev/null iptables -I INPUT 1 -j wh_abuse 2>/dev/null fi local list n=0 tmp score score=$(cat "$STATE/at_score" 2>/dev/null); case "$score" in ''|*[!0-9]*) score=25 ;; esac # Mindest-Score der Community-Blockliste ist über den Parameter "score" steuerbar # (höher = weniger, aber höher bewertete Angreifer-IPs). Standard 25 (breitester Stand). list=$(curl -s --max-time 25 -H "Authorization: Bearer $key" "https://abusetrack.io/api/v1/blocklist.txt?score=$score" 2>/dev/null) if [ -n "$list" ]; then tmp=$(mktemp); echo "$list" | grep -oE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' > "$tmp" n=$(grep -c . "$tmp") ipset flush wachhund_abuse 2>/dev/null while IFS= read -r ip; do ipset add wachhund_abuse "$ip" -exist 2>/dev/null; done < "$tmp" rm -f "$tmp" [ "$n" -gt 0 ] && addp prozesse info "$n bekannte Angreifer-IP(s) aus AbuseTrack gesperrt (Score ab $score)" "AbuseTrack" fi fi return 0 } # Backup-Assistent: sichert /etc + SSH-Schlüssel + Paketliste täglich auf einen # FTP/FTPS-Server (z. B. Nytrix FTP-Storage — nur FTP/FTPS, daher curl statt scp). backup_run() { local cfg="$STATE/backup"; [ -s "$cfg" ] || return 0 have curl || return 0 local host port user pass path ftps host=$(sed -n 's/^host=//p' "$cfg"); port=$(sed -n 's/^port=//p' "$cfg") user=$(sed -n 's/^user=//p' "$cfg"); pass=$(sed -n 's/^pass=//p' "$cfg") path=$(sed -n 's/^path=//p' "$cfg"); ftps=$(sed -n 's/^ftps=//p' "$cfg") [ -n "$host" ] && [ -n "$user" ] || return 0 local today; today=$(date +%Y-%m-%d) [ "$(cat "$STATE/backup_last" 2>/dev/null)" = "$today" ] && return 0 dpkg --get-selections > /etc/wachhund/installed-packages.txt 2>/dev/null local tmp; tmp=$(mktemp /tmp/wh_backup.XXXXXX.tar.gz) tar czf "$tmp" --warning=no-file-changed -C / etc root/.ssh 2>/dev/null local fname="wachhund-$(hostname)-$today.tar.gz" local sslopt=""; [ "$ftps" = "1" ] && sslopt="--ssl-reqd" if curl -sS $sslopt --ftp-create-dirs --max-time 600 --user "$user:$pass" \ -T "$tmp" "ftp://$host:${port:-21}/${path#/}/$fname" 2>/dev/null; then echo "$today" > "$STATE/backup_last" addp gesundheit info "Backup erstellt und aufs FTP-Storage geladen ($(du -h "$tmp" 2>/dev/null | cut -f1))" "Backup" else addp gesundheit warn "Backup-Upload fehlgeschlagen — bitte FTP-Zugang prüfen" "Backup" fi rm -f "$tmp" } # --- Übertragung ------------------------------------------------------------- send() { have curl || die "curl wird benötigt." have python3 || die "python3 wird benötigt." [ -n "${API_TOKEN:-}" ] || die "Kein Zugriffs-Token. Zuerst 'wachhund enroll ' ausführen." CPU=${CPU:-0}; HARDENING=${HARDENING:-100} VER=$(cat "$STATE/version" 2>/dev/null || echo "$VERSION") WH_URL="$API_URL/checkin.php" WH_TOKEN="$API_TOKEN" \ WH_CPU="$CPU" WH_MEMU="$MEM_USED" WH_MEMT="$MEM_TOTAL" WH_DU="$DISK_USED" WH_DT="$DISK_TOTAL" \ WH_HARD="$HARDENING" WH_HRT="${HRT:-}" WH_SCHEMA="$SCORE_SCHEMA" \ WH_VER="$VER" WH_FIND="$FIND" WH_NET="$NET" WH_LOGIN="$LOGIN" WH_INV="$INV" WH_ACT="$ACT" WH_TOOL="$STATE/toolstat" WH_DONE="$STATE/done_tasks" WH_TP="$STATE/tool_progress" WH_ST="$STATE" python3 - <<'PY' import os, json, urllib.request, urllib.error, hashlib, base64, subprocess # Ende-zu-Ende: liegt der öffentliche Nutzer-Schlüssel vor, werden Befund-Details vor # dem Senden clientseitig verschlüsselt (RSA-OAEP/SHA-256). Die Zentrale sieht dann nur # Chiffretext; entschlüsselt wird erst im Browser des Nutzers. Ohne Schlüssel unverändert. E2E_PUB = os.path.join(os.environ.get('WH_ST',''), 'e2e_pub.pem') _e2e_on = bool(os.environ.get('WH_ST')) and os.path.exists(E2E_PUB) def seal(detail): if not detail or not _e2e_on: return detail b = detail.encode('utf-8') if len(b) > 300: # RSA-3072-OAEP-SHA256 fasst max. 318 Byte b = b[:300] while b and (b[-1] & 0xC0) == 0x80: # nicht mitten in einem UTF-8-Zeichen abschneiden b = b[:-1] try: p = subprocess.run(['openssl','pkeyutl','-encrypt','-pubin','-inkey',E2E_PUB, '-pkeyopt','rsa_padding_mode:oaep','-pkeyopt','rsa_oaep_md:sha256','-pkeyopt','rsa_mgf1_md:sha256'], input=b, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) if p.returncode == 0 and p.stdout: return 'WHE2E:' + base64.b64encode(p.stdout).decode() except Exception: pass return detail def rows(path, cols): out=[] try: for line in open(path, encoding='utf-8'): line=line.rstrip('\n') if not line: continue parts=line.split('\t'); parts += ['']*(len(cols)-len(parts)) out.append(dict(zip(cols, parts))) except (FileNotFoundError, OSError): pass return out find=[] for r in rows(os.environ['WH_FIND'], ['category','severity','title','source','detail']): r['dedup_hash']=hashlib.sha256((r['category']+r['title']).encode()).hexdigest() r['detail']=seal(r.get('detail','')) find.append(r) net=rows(os.environ['WH_NET'], ['kind','severity','detail']) for r in net: r['detail']=seal(r.get('detail','')) # Logins bewusst NICHT versiegeln: ihre Dedup (seen_hash) hängt am Detail, und RSA-OAEP # ist zufällig — sonst würde jeder Login bei jedem Check-in als neu gezählt. logins=rows(os.environ.get('WH_LOGIN',''), ['kind','detail']) inventory=rows(os.environ.get('WH_INV',''), ['kind','name','allowed','detail']) for r in inventory: r['allowed'] = (r.get('allowed')=='1') activity=rows(os.environ.get('WH_ACT',''), ['kind','detail']) tstatus={} for r in rows(os.environ.get('WH_TOOL',''), ['tkey','installed','status','summary','detail']): tstatus[r['tkey']]={'installed':r['installed']=='1','status':r['status'],'summary':r['summary'],'detail':seal(r.get('detail',''))} # Beim letzten Lauf ausgeführte Aufträge quittieren (Dashboard hakt sie in der Aufgaben-Glocke ab). done_tasks=[] try: done_tasks=sorted({x for x in open(os.environ.get('WH_DONE',''), encoding='utf-8').read().split() if x.isalnum()}) except (FileNotFoundError, OSError): pass # Fortschritt der Tool-Einrichtung (welches Tool zuletzt dran war, wie viele noch offen) tool_progress=None try: _tp=open(os.environ.get('WH_TP',''), encoding='utf-8').read().strip().split('\t') if len(_tp)>=3 and _tp[0]=='setup': tool_progress={'current':_tp[1][:40],'remaining':int(_tp[2])} except (FileNotFoundError, OSError, ValueError): pass # Einzelkriterien der Härtung: nur Schlüssel, Gewicht und erfüllt/nicht erfüllt. Die # erklärenden Texte stehen im Dashboard, damit sie sich ohne Agent-Update verbessern lassen. haert=[] for r in rows(os.environ.get('WH_HRT',''), ['key','pts','ok']): try: haert.append({'key':r['key'][:24],'pts':int(r['pts']),'ok':r['ok']=='1'}) except ValueError: pass body=json.dumps({ 'heartbeat':{'cpu':float(os.environ['WH_CPU']),'mem_used':int(os.environ['WH_MEMU']),'mem_total':int(os.environ['WH_MEMT']),'disk_used':int(os.environ['WH_DU']),'disk_total':int(os.environ['WH_DT'])}, 'hardening_score':int(os.environ['WH_HARD']),'version':os.environ['WH_VER'], 'hardening':{'schema':int(os.environ.get('WH_SCHEMA','1')),'items':haert}, 'findings':find,'net_events':net,'logins':logins,'inventory':inventory,'activity':activity,'tool_status':tstatus,'done_tasks':done_tasks,'tool_progress':tool_progress, }).encode() req=urllib.request.Request(os.environ['WH_URL'], data=body, headers={'Content-Type':'application/json','Authorization':'Bearer '+os.environ['WH_TOKEN']}) try: print(urllib.request.urlopen(req, timeout=25).read().decode()) except urllib.error.HTTPError as ex: print('{"revoked":true}' if ex.code == 401 else '{"error":"http %d"}' % ex.code) except Exception as ex: print('{"error":"%s"}' % str(ex).replace('"',"'")) PY } # Zugriff im Dashboard widerrufen? Nach mehrfacher Bestätigung entfernt sich der Agent # vollständig selbst. Absichtlich defensiv: ein einzelner 401 (z. B. kurzer Server-Aussetzer) # löst nichts aus — erst drei aufeinanderfolgende Widerrufe. Jeder erfolgreiche Check-in # setzt den Zähler zurück, Netzfehler lassen ihn unverändert. check_revoked() { case "$1" in *'"revoked":true'*) local c; c=$(cat "$STATE/revoked_count" 2>/dev/null); case "$c" in ''|*[!0-9]*) c=0 ;; esac c=$((c+1)); echo "$c" > "$STATE/revoked_count" if [ "$c" -ge 3 ]; then echo "Zugriff im Dashboard widerrufen (mehrfach bestätigt) — Wachhund entfernt sich jetzt vollständig." >&2 cmd_uninstall exit 0 fi echo "Zugriff scheint widerrufen (Bestätigung $c/3) — Übertragung pausiert bis zur nächsten Prüfung." >&2 exit 0 ;; *'"ok":true'*) rm -f "$STATE/revoked_count" ;; esac } # Baseline- und Tool-Schalter aus der Antwort für den nächsten Lauf merken. # Antwort als Umgebungsvariable übergeben — stdin ist durch das Heredoc-Programm belegt. persist_config() { WH_ST="$STATE" WH_RESP="$1" python3 - <<'PY' import os, json try: d=json.loads(os.environ.get('WH_RESP','') or '{}') except Exception: raise SystemExit(0) st=os.environ['WH_ST'] def w(name, arr): arr=[x for x in arr if isinstance(x, str) and x.replace('_','').isalnum()] open(os.path.join(st, name), 'w').write(' '.join(arr)) w('baseline', d.get('baseline', []) or []) w('tools', d.get('tools', []) or []) at=d.get('abusetrack') if isinstance(at, dict) and at.get('key'): p=os.path.join(st,'at_key'); open(p,'w').write(str(at['key'])); os.chmod(p, 0o600) fl=[] if at.get('report'): fl.append('report') if at.get('block'): fl.append('block') open(os.path.join(st,'at_flags'),'w').write(' '.join(fl)) try: sc=int(at.get('score',25)) except (TypeError, ValueError): sc=25 open(os.path.join(st,'at_score'),'w').write(str(min(100, max(25, sc)))) else: for f in ('at_key','at_flags','at_score'): try: os.remove(os.path.join(st,f)) except OSError: pass up=d.get('update') if isinstance(up, dict) and up.get('version'): open(os.path.join(st,'pending_update'),'w').write(str(up['version'])) open(os.path.join(st,'pending_update_sig'),'w').write(str(up.get('sig') or '')) else: for f in ('pending_update','pending_update_sig'): try: os.remove(os.path.join(st,f)) except OSError: pass ts=d.get('tool_settings') if isinstance(ts, dict): for tk,sv in ts.items(): if isinstance(tk,str) and tk.isalnum() and isinstance(sv,dict): out='' for k,v in sv.items(): if isinstance(k,str) and k.isalnum() and isinstance(v,(int,float)): out+='%s=%d\n'%(k,int(v)) open(os.path.join(st,'toolset_'+tk),'w').write(out) tk=d.get('tasks') if isinstance(tk, list): open(os.path.join(st,'tasks'),'w').write(' '.join(x for x in tk if isinstance(x,str) and x.isalnum())) bk=d.get('backup') if isinstance(bk, dict) and bk.get('host') and bk.get('user'): lines=''.join('%s=%s\n'%(k, str(bk.get(k,'')).replace('\n','').replace('\r','')) for k in ('host','port','user','pass','path','ftps')) p=os.path.join(st,'backup'); open(p,'w').write(lines); os.chmod(p, 0o600) else: try: os.remove(os.path.join(st,'backup')) except OSError: pass # Öffentlicher E2E-Schlüssel des Nutzers (nicht geheim): merken, damit send() Details versiegelt. pub=d.get('e2e_pubkey') if isinstance(pub, str) and 'PUBLIC KEY' in pub and len(pub) < 4000: open(os.path.join(st,'e2e_pub.pem'),'w').write(pub) else: try: os.remove(os.path.join(st,'e2e_pub.pem')) except OSError: pass PY } # System-Updates über die gängigen Paketverwaltungen. Debian/Ubuntu/Proxmox nutzen apt; # zusätzlich dnf/yum (RHEL/Fedora/Rocky/Alma), zypper (openSUSE) und pacman (Arch). os_update() { local n=0 if have apt-get; then apt-get update -qq >/dev/null 2>&1 n=$(DEBIAN_FRONTEND=noninteractive apt-get -y -qq -o Dpkg::Options::=--force-confold upgrade 2>&1 | grep -ciE '^(Setting up|Unpacking) ') elif have dnf; then n=$(dnf -y --refresh upgrade 2>&1 | grep -ciE '^[[:space:]]*(Upgrading|Installing|Updating)[[:space:]]') elif have yum; then n=$(yum -y update 2>&1 | grep -ciE '^[[:space:]]*(Updating|Installing)[[:space:]]') elif have zypper; then zypper --non-interactive refresh >/dev/null 2>&1 n=$(zypper --non-interactive update -y 2>&1 | grep -ciE '^(Installing|Updating):') elif have pacman; then n=$(pacman -Syu --noconfirm 2>&1 | grep -ciE '^(installing|upgrading) ') else addp updates warn "System-Updates nicht möglich: keine bekannte Paketverwaltung gefunden (apt/dnf/yum/zypper/pacman)" "Update"; return 0 fi case "$n" in ''|*[!0-9]*) n=0 ;; esac addp updates info "${n} System-Update(s) über das Dashboard eingespielt" "Update" } # Freigegebene Aufträge aus dem Dashboard ausführen (feste Liste, kein Code vom Panel). # Jede bearbeitete Aufgabe wird beim nächsten Kontakt quittiert (Aufgaben-Glocke im Dashboard). run_tasks() { local tasks t; tasks=$(cat "$STATE/tasks" 2>/dev/null); [ -n "$tasks" ] || return 0 : > "$STATE/tasks" for t in $tasks; do case "$t" in rebaseline) rm -f "$STATE/.initialized" "$STATE/.act_init" "$STATE/allowed_ports" "$STATE/allowed_svcs" # Auch die Prüfsummen-Datenbanken der Integritäts-Tools neu einlernen, damit gewollte # Änderungen (z. B. nach einem Update) nicht weiter als Abweichung gemeldet werden. have rkhunter && rkhunter --propupd --quiet >/dev/null 2>&1 rm -f /var/lib/aide/aide.db 2>/dev/null # AIDE legt seine Datenbank beim nächsten Kontakt neu an addp konten info "Normalzustand neu eingelernt — Konten, Ports, Dienste und die Prüfsummen-Datenbanken (AIDE/rkhunter)" "Baseline" ;; osupdate) os_update ;; scan) # Gründlicher Rundgang auf Anforderung. WICHTIG: Tiefenscans aller Tools (v. a. der # ClamAV-Vollscan) können mit vielen aktivierten Werkzeugen etliche Minuten dauern — # das darf den Minuten-Herzschlag NICHT blockieren, sonst greift dessen Zeitlimit und # die Aufgabe bleibt hängen. Deshalb den separaten Scan-Dienst im Hintergrund anstoßen. rm -f "$STATE"/tool_*_ts 2>/dev/null # Throttle aufheben, damit die Scans jetzt laufen if have systemctl && systemctl start --no-block wachhund-scan.service 2>/dev/null; then addp gesundheit info "Gründlicher Rundgang gestartet — die Ergebnisse folgen in Kürze" "Rundgang" else ( WH_DEEP=1 manage_tools ) # Fallback ohne systemd: bestmöglich direkt addp gesundheit info "Gründlicher Rundgang durchgeführt" "Rundgang" fi ;; esac printf '%s\n' "$t" >> "$STATE/done_tasks" done } # Prüft die ed25519-Signatur eines heruntergeladenen Agenten gegen AGENT_PUBKEY. # Rückgabe 0 = einspielen erlaubt (gültige Signatur, oder mangels geliefertem sig bzw. # fehlender openssl-Rohprüfung nicht strenger als das bisherige TLS-Vertrauen). # Rückgabe 1 = eine Signatur liegt vor, ist aber ungültig -> Update ablehnen. verify_update_sig() { local f="$1" sig; sig=$(cat "$STATE/pending_update_sig" 2>/dev/null) if [ -z "$sig" ]; then addp updates info "Agent-Update ohne Signatur eingespielt (nur über TLS geprüft)" "Update"; return 0 fi if ! have openssl || ! openssl pkeyutl -help 2>&1 | grep -q -- '-rawin'; then addp updates info "Agent-Update-Signatur nicht geprüft (openssl ohne ed25519-Rohprüfung) — nur TLS" "Update"; return 0 fi local pub sigbin rc pub=$(mktemp); printf '%s\n' "$AGENT_PUBKEY" > "$pub" sigbin=$(mktemp); printf '%s' "$sig" | base64 -d > "$sigbin" 2>/dev/null if openssl pkeyutl -verify -pubin -inkey "$pub" -rawin -in "$f" -sigfile "$sigbin" >/dev/null 2>&1; then rc=0; else rc=1; fi rm -f "$pub" "$sigbin" return "$rc" } # Sicheres Selbst-Update: nur wenn im Dashboard freigegeben (pending_update gesetzt). # Lädt über TLS von wachhund.io, prüft gültiges Bash + erwartete Version + Signatur, # ersetzt erst danach atomar. apply_update() { local newver curver; newver=$(cat "$STATE/pending_update" 2>/dev/null); [ -n "$newver" ] || return 0 curver=$(cat "$STATE/version" 2>/dev/null || echo "$VERSION") [ "$newver" = "$curver" ] && { rm -f "$STATE/pending_update" "$STATE/pending_update_sig"; return 0; } have curl || return 0 local tmp="/usr/local/bin/.wachhund.new" if curl -fsSL --max-time 30 "https://wachhund.io/wachhund" -o "$tmp" 2>/dev/null; then local dlver; dlver=$(grep -m1 '^VERSION=' "$tmp" | cut -d= -f2) if head -1 "$tmp" | grep -q 'bash' && [ "$dlver" = "$newver" ] && bash -n "$tmp" 2>/dev/null && verify_update_sig "$tmp"; then chmod 755 "$tmp"; mv -f "$tmp" /usr/local/bin/wachhund echo "$newver" > "$STATE/version"; rm -f "$STATE/pending_update" "$STATE/pending_update_sig" addp updates info "Wachhund-Agent auf Version $newver aktualisiert" "Update" printf 'update\n' >> "$STATE/done_tasks" else rm -f "$tmp"; addp updates warn "Agent-Update abgelehnt (Prüfung oder Signatur fehlgeschlagen)" "Update" fi else rm -f "$tmp"; fi } # --- Unterbefehle ------------------------------------------------------------ cmd_enroll() { local token="$1" [ -n "$token" ] || die "Einrichtungs-Token fehlt." have curl || die "curl wird benötigt." local host os ip host=$(hostname); os=$(. /etc/os-release 2>/dev/null; echo "${PRETTY_NAME:-Linux}") ip=$(curl -fsS --max-time 8 https://api.ipify.org 2>/dev/null || echo "") local resp; resp=$(curl -fsS --max-time 20 -X POST "$API_URL/enroll.php" \ -H 'Content-Type: application/json' \ -d "$(python3 -c "import json,sys;print(json.dumps({'enroll_token':sys.argv[1],'hostname':sys.argv[2],'os':sys.argv[3],'ip':sys.argv[4],'version':'$VERSION'}))" "$token" "$host" "$os" "$ip")" ) local api; api=$(echo "$resp" | python3 -c "import json,sys;print(json.load(sys.stdin).get('api_token',''))" 2>/dev/null) [ -n "$api" ] || die "Anmeldung fehlgeschlagen: $resp" mkdir -p /etc/wachhund { echo "API_URL=$API_URL"; echo "API_TOKEN=$api"; } > "$CONF" chmod 600 "$CONF" echo "$VERSION" > "$STATE/version" API_TOKEN="$api" echo "Erfolgreich angemeldet. Der Server erscheint jetzt im Dashboard." cmd_checkin } run_collect() { [ -n "${FIND:-}" ] && rm -f "$FIND" "$NET" "$LOGIN" "$TOOLSTAT" "${INV:-}" "${ACT:-}" "${HRT:-}" 2>/dev/null # Reste eines vorherigen Schnell-Takt-Laufs FIND=$(mktemp); NET=$(mktemp); LOGIN=$(mktemp); TOOLSTAT=$(mktemp); INV=$(mktemp); ACT=$(mktemp); HRT=$(mktemp) trap 'rm -f "$FIND" "$NET" "$LOGIN" "$TOOLSTAT" "$INV" "$ACT" "$HRT"' EXIT # Gepufferte Meldungen aus dem letzten Lauf (nach dem Senden entstanden) mitschicken [ -f "$STATE/pending" ] && { cat "$STATE/pending" >> "$FIND"; rm -f "$STATE/pending"; } metrics collect_logins checks inventory activity_scan } run_enforce() { apply_baseline; manage_tools; } # Selbstheilung des Herzschlag-Timers: stellt sicher, dass Service + Timer existieren, ein # Zeitlimit haben (ein hängender Kontakt — z. B. apt-Lock oder langer Scan — darf den Minuten- # Takt nicht dauerhaft blockieren) und laufen. Idempotent und billig: nur bei echter Abweichung # wird geschrieben und neu geladen. Behebt „meldet sich nicht von selbst"-Aussetzer. ensure_timers() { [ "$(id -u)" = 0 ] || return 0 have systemctl || return 0 local changed=0 svc=/etc/systemd/system/wachhund-checkin.service tmr=/etc/systemd/system/wachhund-checkin.timer local want_svc='[Unit] Description=Wachhund Herzschlag und Check-in After=network-online.target Wants=network-online.target [Service] Type=oneshot TimeoutStartSec=150 ExecStart=/usr/local/bin/wachhund checkin' local want_tmr='[Unit] Description=Wachhund Herzschlag (minütlich) [Timer] OnActiveSec=20 OnUnitActiveSec=60 Persistent=true [Install] WantedBy=timers.target' [ "$(cat "$svc" 2>/dev/null)" = "$want_svc" ] || { printf '%s\n' "$want_svc" > "$svc"; changed=1; } [ "$(cat "$tmr" 2>/dev/null)" = "$want_tmr" ] || { printf '%s\n' "$want_tmr" > "$tmr"; changed=1; } # Tiefenscan-Dienst: läuft im Hintergrund (auf Anforderung + nachts) und darf lange dauern — # großzügiges Zeitlimit als reines Sicherheitsnetz, falls ein Tool wirklich hängt. local scv=/etc/systemd/system/wachhund-scan.service local want_scv='[Unit] Description=Wachhund Tiefenscan After=network-online.target Wants=network-online.target [Service] Type=oneshot TimeoutStartSec=1800 ExecStart=/usr/local/bin/wachhund scan' [ "$(cat "$scv" 2>/dev/null)" = "$want_scv" ] || { printf '%s\n' "$want_scv" > "$scv"; changed=1; } [ "$changed" = 1 ] && systemctl daemon-reload 2>/dev/null systemctl is-active wachhund-checkin.timer >/dev/null 2>&1 || systemctl enable --now wachhund-checkin.timer >/dev/null 2>&1 return 0 } cmd_checkin() { ensure_timers local burst=0 r more while :; do run_collect; r=$(send); check_revoked "$r" case "$r" in *'"ok":true'*) rm -f "$STATE/done_tasks" ;; esac persist_config "$r"; run_tasks # Härtung + Tool-Einrichtung (kann apt-Installationen anstoßen) nur EINMAL je Aufruf — # nicht in jeder Schnell-Takt-Runde, sonst blockiert eine Installation den Prozess zu lange. [ "$burst" = 0 ] && { run_enforce; apply_update; } # Schnell-Takt: nur für die zügige Quittierung ausgeführter/eingereihter Aufgaben bzw. # ein anstehendes Update — in ~8 s erneut melden. Rein ausgehend, wenige Runden. # Tool-Installationen laufen bewusst über den normalen Minuten-Takt (ein Tool je Kontakt). burst=$((burst+1)); [ "$burst" -ge 4 ] && break more=0 [ -s "$STATE/done_tasks" ] && more=1 [ -s "$STATE/tasks" ] && more=1 [ -f "$STATE/pending_update" ] && more=1 case "$r" in *'"poll_soon":true'*) more=1 ;; esac [ "$more" = 1 ] || break sleep 8 done echo "Übertragen." } cmd_scan() { WH_DEEP=1; run_collect; local r; r=$(send); check_revoked "$r"; case "$r" in *'"ok":true'*) rm -f "$STATE/done_tasks" ;; esac; persist_config "$r"; run_enforce; update_tools; abusetrack_run; backup_run; echo "Scan übertragen."; } cmd_test() { run_collect; echo "Testlauf (nichts gesendet, keine Härtung):"; echo " CPU ${CPU}% RAM $((MEM_USED/1024/1024))/$((MEM_TOTAL/1024/1024)) MB"; echo " Logins:"; sed 's/^/ /' "$LOGIN" 2>/dev/null; echo " Befunde:"; sed 's/^/ /' "$FIND" 2>/dev/null; echo " Inventar (kind name erlaubt proc):"; sed 's/^/ /' "$INV" 2>/dev/null; echo " Verlauf:"; sed 's/^/ /' "$ACT" 2>/dev/null; } cmd_uninstall() { systemctl disable --now wachhund-checkin.timer wachhund-scan.timer >/dev/null 2>&1 rm -f /etc/systemd/system/wachhund-checkin.service /etc/systemd/system/wachhund-checkin.timer \ /etc/systemd/system/wachhund-scan.service /etc/systemd/system/wachhund-scan.timer systemctl daemon-reload >/dev/null 2>&1 # Von Wachhund angelegte Firewall-Reste der AbuseTrack-Sperrliste sauber entfernen, # damit keine verwaiste DROP-Regel bzw. kein leeres ipset zurückbleibt. if have iptables; then iptables -D INPUT -j wh_abuse 2>/dev/null iptables -F wh_abuse 2>/dev/null iptables -X wh_abuse 2>/dev/null fi have ipset && ipset destroy wachhund_abuse 2>/dev/null # Von Wachhund installierte Sicherheitstools wieder entfernen, damit der Server wie vorher ist — # aber nur die, die WIR selbst installiert haben (vorher vorhandene Pakete bleiben unberührt). local removed=0 if [ -f /var/lib/wachhund/installed_pkgs ] && have apt-get; then local wpkgs; wpkgs=$(tr '\n' ' ' < /var/lib/wachhund/installed_pkgs) if [ -n "$wpkgs" ]; then systemctl stop fail2ban auditd arpwatch psad acct >/dev/null 2>&1 DEBIAN_FRONTEND=noninteractive apt-get purge -y -qq $wpkgs >/dev/null 2>&1 DEBIAN_FRONTEND=noninteractive apt-get autoremove -y -qq >/dev/null 2>&1 removed=1 fi fi rm -rf /var/lib/wachhund /etc/wachhund rm -f /usr/local/bin/wachhund if [ "$removed" = 1 ]; then echo "Wachhund vollständig entfernt: Agent, Timer, Zustand, Zugangsdaten, eigene Firewall-Kette und die von Wachhund installierten Sicherheitstools. Die Baseline-Härtung (SSH-/Firewall-Einstellungen) bleibt bewusst erhalten — ihr Rückbau könnte dich aussperren; passe sie bei Bedarf selbst an." else echo "Wachhund vollständig entfernt (Agent, Timer, Zustand, Zugangsdaten, eigene Firewall-Kette). Es waren keine von Wachhund installierten Tools zu entfernen. Baseline-Härtung bleibt erhalten." fi } case "${1:-}" in enroll) cmd_enroll "${2:-}" ;; checkin) cmd_checkin ;; scan) cmd_scan ;; uninstall) cmd_uninstall ;; --test) cmd_test ;; *) echo "Wachhund-Agent $VERSION. Aufruf: wachhund {enroll |checkin|scan|uninstall|--test}" ;; esac