Das ist der komplette Wachhund-Agent — lesbares Bash, nichts versteckt. Genau diese Datei installierst du, und genau sie kannst du hier Zeile für Zeile nachlesen, bevor du ihr vertraust.
| 1 | #!/usr/bin/env bash |
| 2 | # Developed by Enjyn Gruppe — https://enjyn.de |
| 3 | # Wachhund-Agent. Verbindet sich nur ausgehend per HTTPS zur Zentrale. |
| 4 | # Wichtig zum Vertrauensmodell: Baseline-Härtung und Store-Tools werden NUR aus einer |
| 5 | # festen, hier einprogrammierten Liste angewandt. Die Zentrale schickt lediglich |
| 6 | # Schalter-Zustände (welche Schlüssel aktiv sind), niemals ausführbare Befehle. |
| 7 | set -u |
| 8 | |
| 9 | VERSION=2.2.24 |
| 10 | API_URL="${WACHHUND_API:-https://wachhund.io/api}" |
| 11 | CONF=/etc/wachhund/agent.env |
| 12 | STATE=/var/lib/wachhund |
| 13 | [ -r "$CONF" ] && . "$CONF" |
| 14 | |
| 15 | # Öffentlicher ed25519-Schlüssel zur Prüfung signierter Agent-Updates. Der private |
| 16 | # Schlüssel liegt offline beim Betreiber, nie auf dem Server — so kann selbst ein |
| 17 | # kompromittierter Webserver kein gültig signiertes Update ausliefern. |
| 18 | AGENT_PUBKEY='-----BEGIN PUBLIC KEY----- |
| 19 | MCowBQYDK2VwAyEAYVaeS1du6zgaPSSoc1VoEqwgWTVCD+q5xxp0/woLu/8= |
| 20 | -----END PUBLIC KEY-----' |
| 21 | |
| 22 | die() { echo "Fehler: $1" >&2; exit 1; } |
| 23 | have() { command -v "$1" >/dev/null 2>&1; } |
| 24 | mkdir -p "$STATE" 2>/dev/null |
| 25 | # Der laufende Code ist maßgeblich: die gemerkte Version an die tatsächliche angleichen, |
| 26 | # damit Dashboard-Anzeige und Update-Vergleich nach einem manuellen Deploy nicht veralten. |
| 27 | [ "$(cat "$STATE/version" 2>/dev/null)" = "$VERSION" ] || printf '%s\n' "$VERSION" > "$STATE/version" 2>/dev/null |
| 28 | |
| 29 | # --- Datensammlung ----------------------------------------------------------- |
| 30 | |
| 31 | metrics() { |
| 32 | read -r _ a b c d e f g _ < /proc/stat |
| 33 | local idle1=$((d+e)) tot1=$((a+b+c+d+e+f+g)) |
| 34 | sleep 1 |
| 35 | read -r _ a b c d e f g _ < /proc/stat |
| 36 | local idle2=$((d+e)) tot2=$((a+b+c+d+e+f+g)) |
| 37 | local dt=$((tot2-tot1)) di=$((idle2-idle1)) |
| 38 | CPU=0; [ "$dt" -gt 0 ] && CPU=$(( (100*(dt-di)) / dt )) |
| 39 | MEM_TOTAL=$(awk '/MemTotal/{print $2*1024}' /proc/meminfo) |
| 40 | local avail; avail=$(awk '/MemAvailable/{print $2*1024}' /proc/meminfo) |
| 41 | MEM_USED=$((MEM_TOTAL-avail)) |
| 42 | local df; df=$(df -kP / | awk 'NR==2{print $2, $3}') |
| 43 | DISK_TOTAL=$(( $(echo "$df" | cut -d' ' -f1) * 1024 )) |
| 44 | DISK_USED=$(( $(echo "$df" | cut -d' ' -f2) * 1024 )) |
| 45 | } |
| 46 | |
| 47 | add() { printf '%s\t%s\t%s\t%s\t%s\n' "$1" "$2" "$3" "$4" "${5:-}" >> "$FIND"; } |
| 48 | # Meldung aus Funktionen NACH dem Senden: in dauerhaften Puffer, wird beim nächsten Check-in gesendet. |
| 49 | addp() { printf '%s\t%s\t%s\t%s\t%s\n' "$1" "$2" "$3" "$4" "${5:-}" >> "$STATE/pending"; } |
| 50 | net() { printf '%s\t%s\t%s\n' "$1" "$2" "$3" >> "$NET"; } |
| 51 | logn() { printf '%s\t%s\n' "$1" "$(echo "$2" | tr -s ' ' | sed 's/[[:space:]]*$//')" >> "$LOGIN"; } |
| 52 | tstat(){ printf '%s\t%s\t%s\t%s\t%s\n' "$1" "$2" "$3" "$4" "$(printf '%s' "${5:-}" | tr '\n\t' ' ')" >> "$STATE/toolstat"; } |
| 53 | |
| 54 | baseline_get() { cat "$STATE/$1" 2>/dev/null; } |
| 55 | baseline_set() { printf '%s\n' "$2" > "$STATE/$1"; } |
| 56 | |
| 57 | # --- Login-Verlauf ----------------------------------------------------------- |
| 58 | |
| 59 | collect_logins() { |
| 60 | if have last; then |
| 61 | last -aw 2>/dev/null | grep -vE '(^reboot | begins |^$)' | head -8 \ |
| 62 | | while IFS= read -r l; do [ -n "$l" ] && logn erfolg "$l"; done |
| 63 | fi |
| 64 | if have lastb; then |
| 65 | lastb -aw 2>/dev/null | grep -vE '( begins |^$)' | head -8 \ |
| 66 | | while IFS= read -r l; do [ -n "$l" ] && logn fehlversuch "$l"; done |
| 67 | fi |
| 68 | if have who; then |
| 69 | who 2>/dev/null | while IFS= read -r l; do [ -n "$l" ] && logn sitzung "$l"; done |
| 70 | fi |
| 71 | } |
| 72 | |
| 73 | # --- Prüfungen --------------------------------------------------------------- |
| 74 | |
| 75 | # Ablaufende TLS-Zertifikate. Geprüft werden Let's-Encrypt-Verzeichnisse und die in nginx/Apache |
| 76 | # eingetragenen Zertifikatsdateien — nicht /etc/ssl/certs, das enthält nur die CA-Sammlung. |
| 77 | certs() { |
| 78 | have openssl || return 0 |
| 79 | local liste="" d f |
| 80 | for d in /etc/letsencrypt/live/*/; do |
| 81 | [ -d "$d" ] || continue |
| 82 | f="$d/cert.pem"; [ -f "$f" ] || f="$d/fullchain.pem" |
| 83 | [ -f "$f" ] && liste="$liste $f" |
| 84 | done |
| 85 | if [ -d /etc/nginx ]; then |
| 86 | liste="$liste $(grep -rhE '^[[:space:]]*ssl_certificate[[:space:]]' /etc/nginx/ 2>/dev/null | awk '{print $2}' | tr -d ';' | sort -u | tr '\n' ' ')" |
| 87 | fi |
| 88 | if [ -d /etc/apache2 ]; then |
| 89 | liste="$liste $(grep -rhE '^[[:space:]]*SSLCertificateFile[[:space:]]' /etc/apache2/ 2>/dev/null | awk '{print $2}' | tr -d '"' | sort -u | tr '\n' ' ')" |
| 90 | fi |
| 91 | |
| 92 | local gesehen="" ende ende_ts tage name jetzt |
| 93 | jetzt=$(date +%s) |
| 94 | for f in $(printf '%s\n' $liste | sort -u); do |
| 95 | [ -f "$f" ] || continue |
| 96 | case " $gesehen " in *" $f "*) continue ;; esac |
| 97 | # Debians Platzhalter-Zertifikat steht oft im Standard-Server-Block, wird aber nie |
| 98 | # wirklich ausgeliefert — ein Ablauf wäre nur ein Fehlalarm. |
| 99 | case "$f" in *snakeoil*) continue ;; esac |
| 100 | gesehen="$gesehen $f" |
| 101 | |
| 102 | ende=$(LC_ALL=C openssl x509 -enddate -noout -in "$f" 2>/dev/null | cut -d= -f2-) |
| 103 | [ -n "$ende" ] || continue |
| 104 | ende_ts=$(LC_ALL=C date -d "$ende" +%s 2>/dev/null) || continue |
| 105 | tage=$(( (ende_ts - jetzt) / 86400 )) |
| 106 | |
| 107 | # Sprechender Name: bei Let's Encrypt der Ordner, sonst der erste Domainname im Zertifikat. |
| 108 | case "$f" in |
| 109 | /etc/letsencrypt/live/*) name=$(basename "$(dirname "$f")") ;; |
| 110 | *) name=$(LC_ALL=C openssl x509 -subject -noout -in "$f" 2>/dev/null | sed 's/.*CN[[:space:]]*=[[:space:]]*//; s/[,/].*//') |
| 111 | [ -n "$name" ] || name=$(basename "$f") ;; |
| 112 | esac |
| 113 | |
| 114 | if [ "$tage" -lt 0 ]; then |
| 115 | add zertifikate critical "TLS-Zertifikat für $name ist abgelaufen" "Zertifikats-Wache" \ |
| 116 | "Abgelaufen seit $(( -tage )) Tag(en). Datei: $f — Besucher sehen eine Sicherheitswarnung." |
| 117 | elif [ "$tage" -le 7 ]; then |
| 118 | add zertifikate critical "TLS-Zertifikat für $name läuft in $tage Tag(en) ab" "Zertifikats-Wache" \ |
| 119 | "Gültig bis $ende. Datei: $f — bitte zeitnah erneuern (bei Let's Encrypt prüfen, ob die automatische Erneuerung läuft)." |
| 120 | elif [ "$tage" -le 30 ]; then |
| 121 | add zertifikate warn "TLS-Zertifikat für $name läuft in $tage Tagen ab" "Zertifikats-Wache" \ |
| 122 | "Gültig bis $ende. Datei: $f" |
| 123 | fi |
| 124 | done |
| 125 | } |
| 126 | |
| 127 | # Festplatten-Gesundheit über SMART. Ohne smartmontools oder auf virtuellen Platten ohne |
| 128 | # SMART-Unterstützung passiert nichts — dort gibt es schlicht nichts zu messen. |
| 129 | disks() { |
| 130 | have smartctl || return 0 |
| 131 | local dev out health name attr |
| 132 | for dev in /dev/sd? /dev/nvme?n?; do |
| 133 | [ -e "$dev" ] || continue |
| 134 | out=$(LC_ALL=C smartctl -H -A "$dev" 2>/dev/null) |
| 135 | printf '%s' "$out" | grep -qiE 'SMART support is:[[:space:]]*Unavailable|Unable to detect device type|Operation not supported' && continue |
| 136 | [ -n "$out" ] || continue |
| 137 | |
| 138 | name=$(basename "$dev") |
| 139 | health=$(printf '%s\n' "$out" | grep -iE 'overall-health self-assessment test result|SMART Health Status' | head -1) |
| 140 | if printf '%s' "$health" | grep -qiE 'FAILED|FAILING'; then |
| 141 | add gesundheit critical "Festplatte $name meldet drohenden Ausfall" "Platten-Wache" \ |
| 142 | "SMART-Gesamtbewertung: FAILED. Daten sichern und die Platte zeitnah tauschen." |
| 143 | continue |
| 144 | fi |
| 145 | printf '%s' "$health" | grep -qiE 'PASSED|OK' || continue |
| 146 | |
| 147 | # Frühwarnzeichen: umgelagerte und wartende Sektoren sind der klassische Vorbote. |
| 148 | for attr in Reallocated_Sector_Ct Current_Pending_Sector Offline_Uncorrectable; do |
| 149 | local wert |
| 150 | wert=$(printf '%s\n' "$out" | awk -v a="$attr" '$2==a{print $10}' | head -1) |
| 151 | case "$wert" in ''|*[!0-9]*) continue ;; esac |
| 152 | [ "$wert" -gt 0 ] && add gesundheit warn "Festplatte $name zeigt erste Ausfallzeichen" "Platten-Wache" \ |
| 153 | "$attr steht auf $wert (normal ist 0). Noch kein Notfall, aber die Platte im Auge behalten und Sicherungen prüfen." |
| 154 | done |
| 155 | |
| 156 | # NVMe: Abnutzungsgrad und Medienfehler |
| 157 | local used med |
| 158 | used=$(printf '%s\n' "$out" | awk -F: '/Percentage Used/{gsub(/[^0-9]/,"",$2); print $2}' | head -1) |
| 159 | med=$(printf '%s\n' "$out" | awk -F: '/Media and Data Integrity Errors/{gsub(/[^0-9]/,"",$2); print $2}' | head -1) |
| 160 | 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 |
| 161 | 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 |
| 162 | done |
| 163 | } |
| 164 | |
| 165 | # Dienste, die nicht mehr laufen. Jeder betroffene Dienst wird einzeln gemeldet, damit man |
| 166 | # sofort sieht, welcher es ist. |
| 167 | services() { |
| 168 | have systemctl || return 0 |
| 169 | local svc grund kurz |
| 170 | for svc in $(systemctl list-units --type=service --state=failed --no-legend --plain 2>/dev/null | awk '{print $1}'); do |
| 171 | kurz=${svc%.service} |
| 172 | grund=$(systemctl show -p Result --value "$svc" 2>/dev/null) |
| 173 | case "$kurz" in |
| 174 | nginx|apache2|httpd|mysql|mariadb|postgresql|docker|containerd|ssh|sshd|php*-fpm|redis*|postfix|dovecot|named|bind9) |
| 175 | add dienste critical "Dienst $kurz läuft nicht mehr" "Dienste-Wache" \ |
| 176 | "systemd meldet den Dienst als fehlgeschlagen${grund:+ (Grund: $grund)}. Das ist ein zentraler Dienst — vermutlich ist die von ihm bereitgestellte Anwendung gerade nicht erreichbar." ;; |
| 177 | *) |
| 178 | add dienste warn "Dienst $kurz ist fehlgeschlagen" "Dienste-Wache" \ |
| 179 | "systemd meldet den Dienst als fehlgeschlagen${grund:+ (Grund: $grund)}. Mit „systemctl status $kurz“ siehst du auf dem Server den genauen Hintergrund." ;; |
| 180 | esac |
| 181 | done |
| 182 | } |
| 183 | |
| 184 | # Persistenz-Orte: hier verankert sich ein Angreifer, damit er einen Neustart überlebt. |
| 185 | # Beobachtet werden Einzeldateien statt nur /etc/crontab — die persönlichen Crontabs unter |
| 186 | # /var/spool/cron sind der klassische Ort, und der lag bisher im toten Winkel. |
| 187 | persistenz() { |
| 188 | local first="$1" jetzt="" f h u alt zeile datei |
| 189 | for f in /etc/cron.d/* /etc/cron.hourly/* /etc/cron.daily/* /etc/cron.weekly/* /etc/cron.monthly/* \ |
| 190 | /var/spool/cron/crontabs/* /var/spool/cron/* /etc/sudoers.d/*; do |
| 191 | [ -f "$f" ] || continue |
| 192 | h=$(sha256sum "$f" 2>/dev/null | cut -d' ' -f1) || continue |
| 193 | jetzt="$jetzt$f $h |
| 194 | " |
| 195 | done |
| 196 | if have systemctl; then |
| 197 | for u in $(systemctl list-unit-files --type=timer --state=enabled --no-legend --plain 2>/dev/null | awk '{print $1}'); do |
| 198 | jetzt="$jetzt timer:$u aktiv |
| 199 | " |
| 200 | done |
| 201 | fi |
| 202 | |
| 203 | alt=$(baseline_get persistenz) |
| 204 | if [ "$first" = 0 ] && [ -n "$alt" ]; then |
| 205 | printf '%s' "$jetzt" | while IFS= read -r zeile; do |
| 206 | [ -n "$zeile" ] || continue |
| 207 | datei=${zeile%% *} |
| 208 | case "$alt" in |
| 209 | *"$zeile"*) continue ;; # unverändert |
| 210 | esac |
| 211 | case "$alt" in |
| 212 | *"$datei "*) |
| 213 | case "$datei" in |
| 214 | /etc/sudoers.d/*) add konten critical "Sudo-Rechte wurden geändert: $(basename "$datei")" "Persistenz-Wache" \ |
| 215 | "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." ;; |
| 216 | timer:*) add dienste warn "Zeitgesteuerte Einheit geändert: ${datei#timer:}" "Persistenz-Wache" "Ein systemd-Timer wurde verändert." ;; |
| 217 | *) add einbruch critical "Geplante Aufgabe wurde geändert: $(basename "$datei")" "Persistenz-Wache" \ |
| 218 | "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." ;; |
| 219 | esac ;; |
| 220 | *) |
| 221 | case "$datei" in |
| 222 | /var/spool/cron/*) add einbruch critical "Neue persönliche Aufgabenliste für Benutzer $(basename "$datei")" "Persistenz-Wache" \ |
| 223 | "Unter $datei ist eine Crontab aufgetaucht, die es beim letzten Einlernen nicht gab. Genau so richtet sich ein Angreifer einen automatischen Wiedereinstieg ein." ;; |
| 224 | /etc/sudoers.d/*) add konten critical "Neue Sudo-Regel angelegt: $(basename "$datei")" "Persistenz-Wache" \ |
| 225 | "In $datei wurden erweiterte Rechte vergeben, die vorher nicht da waren." ;; |
| 226 | timer:*) add dienste warn "Neue zeitgesteuerte Einheit: ${datei#timer:}" "Persistenz-Wache" "Ein systemd-Timer wurde neu aktiviert." ;; |
| 227 | *) add einbruch critical "Neue geplante Aufgabe: $(basename "$datei")" "Persistenz-Wache" \ |
| 228 | "Die Datei $datei ist neu. Bitte prüfen, was dort automatisch ausgeführt wird." ;; |
| 229 | esac ;; |
| 230 | esac |
| 231 | done |
| 232 | fi |
| 233 | baseline_set persistenz "$jetzt" |
| 234 | } |
| 235 | |
| 236 | # Speicherplatz, Inodes und Arbeitsspeicher. Eine volle Platte ist der häufigste Grund, |
| 237 | # warum ein Server real ausfällt — gemessen wurde das bisher, gewarnt hat aber niemand. |
| 238 | kapazitaet() { |
| 239 | local zeile mp benutzt frei_kb jetzt_ts alt rate tage |
| 240 | jetzt_ts=$(date +%s) |
| 241 | |
| 242 | 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 |
| 243 | benutzt=${cap%\%} |
| 244 | case "$benutzt" in ''|*[!0-9]*) continue ;; esac |
| 245 | case "$mp" in /snap/*|/var/lib/docker/*) continue ;; esac |
| 246 | |
| 247 | if [ "$benutzt" -ge 95 ]; then |
| 248 | add gesundheit critical "Festplatte unter $mp ist zu $benutzt % voll" "Kapazitäts-Wache" \ |
| 249 | "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)." |
| 250 | elif [ "$benutzt" -ge 85 ]; then |
| 251 | add gesundheit warn "Festplatte unter $mp ist zu $benutzt % voll" "Kapazitäts-Wache" \ |
| 252 | "Noch $(( _avail / 1024 )) MB frei. Zeit, an Aufräumen zu denken, bevor es eng wird." |
| 253 | fi |
| 254 | |
| 255 | # Grobe Hochrechnung aus dem letzten Messpunkt: nur melden, wenn es wirklich knapp wird. |
| 256 | local key="kap_$(printf '%s' "$mp" | tr -c 'a-zA-Z0-9' '_')" |
| 257 | alt=$(baseline_get "$key") |
| 258 | baseline_set "$key" "$jetzt_ts $used" |
| 259 | if [ -n "$alt" ] && [ "$benutzt" -ge 70 ]; then |
| 260 | local a_ts a_used dt dused |
| 261 | a_ts=${alt%% *}; a_used=${alt##* } |
| 262 | dt=$(( jetzt_ts - a_ts )); dused=$(( used - a_used )) |
| 263 | if [ "$dt" -ge 3600 ] && [ "$dused" -gt 0 ]; then |
| 264 | rate=$(( dused * 86400 / dt )) |
| 265 | [ "$rate" -gt 0 ] && tage=$(( _avail / rate )) |
| 266 | if [ -n "$tage" ] && [ "$tage" -le 14 ] && [ "$tage" -ge 0 ]; then |
| 267 | add gesundheit warn "Festplatte unter $mp ist bei diesem Verlauf in etwa $tage Tagen voll" "Kapazitäts-Wache" \ |
| 268 | "Der belegte Platz wächst derzeit um rund $(( rate / 1024 )) MB pro Tag. Rechtzeitig aufräumen erspart einen Ausfall." |
| 269 | fi |
| 270 | fi |
| 271 | fi |
| 272 | done |
| 273 | |
| 274 | # Inodes: die Platte kann Platz haben und trotzdem keine Datei mehr aufnehmen. |
| 275 | 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 |
| 276 | benutzt=${icap%\%} |
| 277 | case "$benutzt" in ''|*[!0-9]*) continue ;; esac |
| 278 | case "$mp" in /snap/*) continue ;; esac |
| 279 | [ "$benutzt" -ge 90 ] && add gesundheit critical "Dateizähler unter $mp zu $benutzt % erschöpft" "Kapazitäts-Wache" \ |
| 280 | "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)." |
| 281 | done |
| 282 | |
| 283 | # Arbeitsspeicher: erst zusammen mit spürbarem Auslagern ist es wirklich ein Problem. |
| 284 | if [ -r /proc/meminfo ]; then |
| 285 | local mt ma st sf mpct spct |
| 286 | mt=$(awk '/^MemTotal:/{print $2}' /proc/meminfo) |
| 287 | ma=$(awk '/^MemAvailable:/{print $2}' /proc/meminfo) |
| 288 | st=$(awk '/^SwapTotal:/{print $2}' /proc/meminfo) |
| 289 | sf=$(awk '/^SwapFree:/{print $2}' /proc/meminfo) |
| 290 | if [ -n "$mt" ] && [ "$mt" -gt 0 ] && [ -n "$ma" ]; then |
| 291 | mpct=$(( (mt - ma) * 100 / mt )) |
| 292 | spct=0 |
| 293 | [ -n "$st" ] && [ "$st" -gt 0 ] && spct=$(( (st - sf) * 100 / st )) |
| 294 | if [ "$mpct" -ge 92 ] && [ "$spct" -ge 50 ]; then |
| 295 | add gesundheit warn "Arbeitsspeicher ist zu $mpct % belegt und der Auslagerungsspeicher zu $spct %" "Kapazitäts-Wache" \ |
| 296 | "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." |
| 297 | fi |
| 298 | fi |
| 299 | fi |
| 300 | } |
| 301 | |
| 302 | # Programme mit gesetztem Setuid-/Setgid-Bit laufen mit fremden Rechten. Ein neues davon |
| 303 | # an ungewöhnlicher Stelle ist eines der deutlichsten Zeichen für eine Rechteausweitung. |
| 304 | suidwache() { |
| 305 | local first="$1" jetzt alt neu |
| 306 | jetzt=$(find / -xdev \( -path /proc -o -path /sys -o -path /var/lib/docker \) -prune -o \ |
| 307 | -type f \( -perm -4000 -o -perm -2000 \) -print 2>/dev/null | sort) |
| 308 | alt=$(baseline_get suid) |
| 309 | if [ "$first" = 0 ] && [ -n "$alt" ]; then |
| 310 | neu=$(printf '%s\n' "$jetzt" | grep -Fxv -f <(printf '%s\n' "$alt") 2>/dev/null) |
| 311 | local d |
| 312 | for d in $neu; do |
| 313 | case "$d" in |
| 314 | /tmp/*|/var/tmp/*|/dev/shm/*|/home/*|/var/www/*) |
| 315 | add einbruch critical "Neues Programm mit Sonderrechten an ungewöhnlicher Stelle: $d" "Rechte-Wache" \ |
| 316 | "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." ;; |
| 317 | *) |
| 318 | add einbruch warn "Neues Programm mit Sonderrechten: $d" "Rechte-Wache" \ |
| 319 | "Diese Datei darf seit dem letzten Einlernen mit fremden Rechten (Setuid/Setgid) laufen. Stammt das von einer Installation, kannst du den Normalzustand neu einlernen." ;; |
| 320 | esac |
| 321 | done |
| 322 | fi |
| 323 | baseline_set suid "$jetzt" |
| 324 | } |
| 325 | |
| 326 | # Wer sich Zugang verschafft hat, löscht als Nächstes seine Spuren. Schrumpfende |
| 327 | # Anmeldeprotokolle sind dafür das deutlichste Zeichen — die reguläre Rotation wird |
| 328 | # dabei bewusst ausgenommen, sonst gäbe es jeden Monat einen Fehlalarm. |
| 329 | spurenwache() { |
| 330 | local first="$1" f name gr alt rot |
| 331 | for f in /var/log/wtmp /var/log/btmp /var/log/lastlog; do |
| 332 | [ -f "$f" ] || continue |
| 333 | name=$(basename "$f") |
| 334 | gr=$(stat -c %s "$f" 2>/dev/null) || continue |
| 335 | alt=$(baseline_get "logsize_$name") |
| 336 | if [ "$first" = 0 ] && [ -n "$alt" ] && [ "$gr" -lt "$alt" ]; then |
| 337 | rot=0 |
| 338 | # Frisch rotiert? Dann liegt die alte Datei daneben und ist gerade erst entstanden. |
| 339 | [ -f "$f.1" ] && [ -n "$(find "$f.1" -mmin -1500 2>/dev/null)" ] && rot=1 |
| 340 | [ -f "$f.1.gz" ] && [ -n "$(find "$f.1.gz" -mmin -1500 2>/dev/null)" ] && rot=1 |
| 341 | if [ "$rot" = 0 ]; then |
| 342 | add einbruch critical "Anmeldeprotokoll $name wurde verkleinert" "Spuren-Wache" \ |
| 343 | "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." |
| 344 | fi |
| 345 | fi |
| 346 | baseline_set "logsize_$name" "$gr" |
| 347 | done |
| 348 | } |
| 349 | |
| 350 | # Rootkits klinken sich gern als Kernmodul ein. |
| 351 | kernmodule() { |
| 352 | local first="$1" jetzt alt m |
| 353 | have lsmod || return 0 |
| 354 | jetzt=$(lsmod 2>/dev/null | awk 'NR>1{print $1}' | sort | tr '\n' ' ') |
| 355 | alt=$(baseline_get kmods) |
| 356 | if [ "$first" = 0 ] && [ -n "$alt" ]; then |
| 357 | for m in $jetzt; do |
| 358 | case " $alt " in |
| 359 | *" $m "*) : ;; |
| 360 | *) add einbruch warn "Neues Kernmodul geladen: $m" "Kern-Wache" \ |
| 361 | "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." ;; |
| 362 | esac |
| 363 | done |
| 364 | fi |
| 365 | baseline_set kmods "$jetzt" |
| 366 | } |
| 367 | |
| 368 | # Betriebssystem ohne Sicherheitsunterstützung. Die Termine stehen fest im Agenten, damit |
| 369 | # die Prüfung ohne Internetzugriff auskommt — bei neuen Veröffentlichungen hier ergänzen. |
| 370 | lebensende() { |
| 371 | [ -r /etc/os-release ] || return 0 |
| 372 | local id ver eol heute rest name |
| 373 | id=$(. /etc/os-release 2>/dev/null; printf '%s' "${ID:-}") |
| 374 | ver=$(. /etc/os-release 2>/dev/null; printf '%s' "${VERSION_ID:-}") |
| 375 | name=$(. /etc/os-release 2>/dev/null; printf '%s' "${PRETTY_NAME:-$ID $VERSION_ID}") |
| 376 | [ -n "$id" ] && [ -n "$ver" ] || return 0 |
| 377 | |
| 378 | case "$id-$ver" in |
| 379 | debian-9) eol=2022-06-30 ;; |
| 380 | debian-10) eol=2024-06-30 ;; |
| 381 | debian-11) eol=2026-08-31 ;; |
| 382 | debian-12) eol=2028-06-30 ;; |
| 383 | debian-13) eol=2030-06-30 ;; |
| 384 | ubuntu-16.04) eol=2021-04-30 ;; |
| 385 | ubuntu-18.04) eol=2023-05-31 ;; |
| 386 | ubuntu-20.04) eol=2025-05-31 ;; |
| 387 | ubuntu-22.04) eol=2027-06-01 ;; |
| 388 | ubuntu-24.04) eol=2029-06-01 ;; |
| 389 | centos-7) eol=2024-06-30 ;; |
| 390 | centos-8) eol=2021-12-31 ;; |
| 391 | rocky-8|almalinux-8) eol=2029-05-31 ;; |
| 392 | rocky-9|almalinux-9) eol=2032-05-31 ;; |
| 393 | *) return 0 ;; |
| 394 | esac |
| 395 | |
| 396 | heute=$(date +%s) |
| 397 | local eol_ts; eol_ts=$(LC_ALL=C date -d "$eol" +%s 2>/dev/null) || return 0 |
| 398 | rest=$(( (eol_ts - heute) / 86400 )) |
| 399 | if [ "$rest" -lt 0 ]; then |
| 400 | EOL_ST=2 |
| 401 | add updates critical "Betriebssystem bekommt keine Sicherheitsupdates mehr" "Lebenszyklus" \ |
| 402 | "$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." |
| 403 | elif [ "$rest" -le 120 ]; then |
| 404 | EOL_ST=1 |
| 405 | add updates warn "Betriebssystem erreicht in $rest Tagen sein Lebensende" "Lebenszyklus" \ |
| 406 | "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." |
| 407 | fi |
| 408 | } |
| 409 | |
| 410 | # Ein Prozess, der über Stunden fast die volle Rechenleistung zieht, ist das typische |
| 411 | # Bild eines eingeschleusten Krypto-Miners. |
| 412 | dauerlast() { |
| 413 | have ps || return 0 |
| 414 | local zeile pid cpu sek befehl |
| 415 | LC_ALL=C ps -eo pid=,pcpu=,etimes=,comm= --sort=-pcpu 2>/dev/null | head -5 | while read -r pid cpu sek befehl; do |
| 416 | case "$befehl" in |
| 417 | xmrig|minerd|cpuminer*|cryptonight*|nicehash*|ethminer|phoenixminer|t-rex|lolminer) |
| 418 | add prozesse critical "Bekanntes Schürf-Programm läuft: $befehl" "Last-Wache" \ |
| 419 | "Der Prozess „$befehl“ (Nummer $pid) ist als Krypto-Miner bekannt. Solche Programme werden nach einem Einbruch installiert, um auf fremde Kosten zu rechnen." |
| 420 | continue ;; |
| 421 | esac |
| 422 | cpu=${cpu%%.*} |
| 423 | case "$cpu" in ''|*[!0-9]*) continue ;; esac |
| 424 | case "$sek" in ''|*[!0-9]*) continue ;; esac |
| 425 | if [ "$cpu" -ge 80 ] && [ "$sek" -ge 7200 ]; then |
| 426 | add prozesse warn "Prozess $befehl läuft seit Stunden unter Volllast" "Last-Wache" \ |
| 427 | "„$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." |
| 428 | fi |
| 429 | done |
| 430 | } |
| 431 | |
| 432 | # Härtung für Webserver: die Fehler, die Webseiten am häufigsten angreifbar machen. |
| 433 | webhaertung() { |
| 434 | local d ini v |
| 435 | |
| 436 | # Quellcode-Verwaltung im Web-Verzeichnis: liegt .git öffentlich, ist der komplette |
| 437 | # Quelltext samt Zugangsdaten abrufbar — einer der häufigsten schweren Fehler überhaupt. |
| 438 | for d in /var/www/*/ /var/www/html/ /srv/www/*/ /usr/share/nginx/html/; do |
| 439 | [ -d "$d" ] || continue |
| 440 | [ -d "$d.git" ] && add dienste critical "Quellcode-Verwaltung im Web-Verzeichnis: ${d}.git" "Web-Härtung" \ |
| 441 | "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." |
| 442 | [ -f "$d.env" ] && add dienste critical "Konfigurationsdatei mit Zugangsdaten im Web-Verzeichnis: ${d}.env" "Web-Härtung" \ |
| 443 | "Die Datei .env enthält üblicherweise Datenbank-Passwörter und Schlüssel und liegt hier im öffentlich ausgelieferten Bereich." |
| 444 | done |
| 445 | |
| 446 | # Weltweit beschreibbare Verzeichnisse: jeder Nutzer auf dem System kann dort Dateien ablegen. |
| 447 | if [ -d /var/www ]; then |
| 448 | local ww; ww=$(find /var/www -xdev -type d -perm -o+w ! -perm -1000 2>/dev/null | head -3) |
| 449 | [ -n "$ww" ] && add dienste warn "Für alle beschreibbare Verzeichnisse im Web-Bereich" "Web-Härtung" \ |
| 450 | "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." |
| 451 | fi |
| 452 | |
| 453 | # PHP: sichtbare Fehlermeldungen verraten Pfade und Interna. |
| 454 | for ini in /etc/php/*/fpm/php.ini /etc/php/*/apache2/php.ini /etc/php.ini; do |
| 455 | [ -f "$ini" ] || continue |
| 456 | grep -qiE '^[[:space:]]*display_errors[[:space:]]*=[[:space:]]*(on|1|true)' "$ini" 2>/dev/null \ |
| 457 | && add dienste warn "PHP zeigt Fehlermeldungen im Browser an" "Web-Härtung" \ |
| 458 | "In $ini steht display_errors auf An. Fehlermeldungen verraten Dateipfade, Datenbanknamen und manchmal Zugangsdaten. Auf einem öffentlich erreichbaren Server sollte das ausgeschaltet sein." |
| 459 | done |
| 460 | |
| 461 | # PHP-Version ohne Sicherheitsunterstützung |
| 462 | if have php; then |
| 463 | v=$(LC_ALL=C php -r 'echo PHP_MAJOR_VERSION.".".PHP_MINOR_VERSION;' 2>/dev/null) |
| 464 | case "$v" in |
| 465 | 5.*|7.0|7.1|7.2|7.3|7.4|8.0|8.1) |
| 466 | add updates warn "PHP $v bekommt keine Sicherheitsupdates mehr" "Web-Härtung" \ |
| 467 | "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." ;; |
| 468 | esac |
| 469 | fi |
| 470 | } |
| 471 | |
| 472 | # Ausgehende Verbindungen. Ein Einbruch fällt oft erst auf, wenn der Server selbst nach |
| 473 | # draußen telefoniert — Fernsteuerung oder Datenabfluss. Ziel-IPs werden gegen die |
| 474 | # AbuseTrack-Liste ab Score 60 geprüft (nur wenn das Modul eingerichtet ist), unabhängig |
| 475 | # davon fallen dauerhafte Verbindungen auf ungewöhnliche Ports auf. |
| 476 | ausgehend() { |
| 477 | have ss || return 0 |
| 478 | local jetzt zeile ziel ip port proz key bl alt |
| 479 | |
| 480 | # Nur wirklich ausgehende Verbindungen. Liegt der eigene Port auf der Lausch-Liste, hat |
| 481 | # sich jemand bei uns verbunden — dann ist der auffaellige Port bloss der Absenderport |
| 482 | # des Besuchers, und eine Meldung waere ein Fehlalarm. |
| 483 | local lauscher |
| 484 | lauscher=" $(LC_ALL=C ss -tlnH 2>/dev/null | awk '{print $4}' | sed 's/.*://' | sort -un | tr '\n' ' ') " |
| 485 | |
| 486 | jetzt=$(LC_ALL=C ss -tnpH state established 2>/dev/null | awk -v lau="$lauscher" '{ |
| 487 | lport=$3; sub(/.*:/, "", lport); |
| 488 | if (index(lau, " " lport " ") > 0) next; |
| 489 | peer=$4; proc=""; |
| 490 | if (match($0, /users:\(\("[^"]+"/)) { proc=substr($0, RSTART+9, RLENGTH-10) } |
| 491 | print peer "|" proc |
| 492 | }' | grep -vE '^(127\.|10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.|\[::1\]|\[fe80|\[fd)' | sort -u) |
| 493 | |
| 494 | # Eingehende Verbindungen bekannter Angreifer sind trotzdem interessant — nur eben als |
| 495 | # laufender Angriff, nicht als Zeichen eines befallenen Systems. |
| 496 | local eingehend |
| 497 | eingehend=$(LC_ALL=C ss -tnpH state established 2>/dev/null | awk -v lau="$lauscher" '{ |
| 498 | lport=$3; sub(/.*:/, "", lport); |
| 499 | if (index(lau, " " lport " ") == 0) next; |
| 500 | peer=$4; sub(/:[0-9]+$/, "", peer); gsub(/[\[\]]/, "", peer); |
| 501 | print peer "|" lport |
| 502 | }' | grep -vE '^(127\.|10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.|::1|fe80|fd)' | sort -u) |
| 503 | |
| 504 | # Blockliste ab Score 60 einmal täglich holen, solange AbuseTrack eingerichtet ist. |
| 505 | key=$(cat "$STATE/at_key" 2>/dev/null) |
| 506 | if [ -n "$key" ] && have curl; then |
| 507 | local ts alter |
| 508 | ts=$(cat "$STATE/at_bl60_ts" 2>/dev/null || echo 0) |
| 509 | alter=$(( $(date +%s) - ts )) |
| 510 | if [ "$alter" -ge 82800 ] || [ ! -s "$STATE/at_bl60" ]; then |
| 511 | curl -s --max-time 25 -H "Authorization: Bearer $key" \ |
| 512 | "https://abusetrack.io/api/v1/blocklist.txt?score=60" -o "$STATE/at_bl60.neu" 2>/dev/null \ |
| 513 | && [ -s "$STATE/at_bl60.neu" ] \ |
| 514 | && { mv -f "$STATE/at_bl60.neu" "$STATE/at_bl60"; date +%s > "$STATE/at_bl60_ts"; } |
| 515 | rm -f "$STATE/at_bl60.neu" |
| 516 | fi |
| 517 | fi |
| 518 | |
| 519 | alt=$(cat "$STATE/ausgehend_vorher" 2>/dev/null) |
| 520 | printf '%s\n' "$jetzt" > "$STATE/ausgehend_vorher" |
| 521 | |
| 522 | if [ -s "$STATE/at_bl60" ] && [ -n "$eingehend" ]; then |
| 523 | printf '%s\n' "$eingehend" | while IFS='|' read -r qip lport; do |
| 524 | [ -n "$qip" ] || continue |
| 525 | grep -qxF "$qip" "$STATE/at_bl60" 2>/dev/null || continue |
| 526 | add prozesse warn "Bekannter Angreifer ist mit Port $lport verbunden" "Ausgang-Wache" \ |
| 527 | "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." |
| 528 | done |
| 529 | fi |
| 530 | |
| 531 | [ -n "$jetzt" ] || return 0 |
| 532 | printf '%s\n' "$jetzt" | while IFS='|' read -r ziel proz; do |
| 533 | [ -n "$ziel" ] || continue |
| 534 | ip=${ziel%:*}; port=${ziel##*:} |
| 535 | ip=$(printf '%s' "$ip" | tr -d '[]') |
| 536 | case "$port" in ''|*[!0-9]*) continue ;; esac |
| 537 | |
| 538 | # Bekannter Angreifer als Gegenstelle: das ist immer dringend. |
| 539 | if [ -s "$STATE/at_bl60" ] && grep -qxF "$ip" "$STATE/at_bl60" 2>/dev/null; then |
| 540 | add einbruch critical "Verbindung zu bekannter Angreifer-Adresse${proz:+ durch $proz}" "Ausgang-Wache" \ |
| 541 | "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." |
| 542 | continue |
| 543 | fi |
| 544 | |
| 545 | # Ungewöhnlicher Zielport, der schon beim vorigen Durchgang bestand: nur dann melden, |
| 546 | # damit kurzlebige Verbindungen kein Rauschen erzeugen. |
| 547 | case "$port" in |
| 548 | 80|443|53|22|25|465|587|993|995|110|143|123|873|3306|5432|6379|11211|9418|21|20) continue ;; |
| 549 | esac |
| 550 | case "$alt" in |
| 551 | *"$ziel|"*) : ;; |
| 552 | *) continue ;; |
| 553 | esac |
| 554 | add prozesse warn "Dauerhafte Verbindung nach außen auf ungewöhnlichem Port${proz:+ durch $proz}" "Ausgang-Wache" \ |
| 555 | "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." |
| 556 | done |
| 557 | } |
| 558 | |
| 559 | # Läuft die Uhr falsch, brechen Zertifikatsprüfungen und Protokolle werden wertlos — |
| 560 | # ein stiller Fehler, den kaum jemand bemerkt. |
| 561 | zeitpruefung() { |
| 562 | have timedatectl || return 0 |
| 563 | local sync ntp |
| 564 | # Kurz nach einem Neustart ist die Uhr noch nicht abgeglichen — das ist normal und wäre |
| 565 | # ein Fehlalarm. Erst ab zehn Minuten Laufzeit urteilen. |
| 566 | local lauf; lauf=$(cut -d. -f1 /proc/uptime 2>/dev/null) |
| 567 | case "$lauf" in ''|*[!0-9]*) lauf=99999 ;; esac |
| 568 | [ "$lauf" -lt 600 ] && return 0 |
| 569 | |
| 570 | sync=$(LC_ALL=C timedatectl show -p NTPSynchronized --value 2>/dev/null) |
| 571 | ntp=$(LC_ALL=C timedatectl show -p NTP --value 2>/dev/null) |
| 572 | [ "$sync" = "yes" ] && ZEIT_OK=1 |
| 573 | if [ "$ntp" = "no" ]; then |
| 574 | ZEIT_OK=0 |
| 575 | add gesundheit warn "Zeitabgleich ist abgeschaltet" "Zeit-Wache" \ |
| 576 | "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." |
| 577 | elif [ "$sync" = "no" ]; then |
| 578 | ZEIT_OK=0 |
| 579 | add gesundheit warn "Uhr ist nicht mit einem Zeitserver abgeglichen" "Zeit-Wache" \ |
| 580 | "Der Zeitabgleich ist zwar eingeschaltet, war aber bislang nicht erfolgreich. Meist blockiert eine Firewall den Zugang zum Zeitserver (Port 123)." |
| 581 | fi |
| 582 | } |
| 583 | |
| 584 | # Reste einer Standardinstallation der Datenbank. Geprüft wird nur, wenn wir ohnehin |
| 585 | # über den lokalen Systemzugang hereinkommen — es wird nie ein Passwort geraten. |
| 586 | db_haertung() { |
| 587 | have mysql || return 0 |
| 588 | mysql --connect-timeout=3 -N -B -e "SELECT 1" >/dev/null 2>&1 || return 0 |
| 589 | |
| 590 | local ohne anon jeder test |
| 591 | # Konten ohne Passwort. Socket-Anmeldung ist sicher und wird ausgenommen. |
| 592 | ohne=$(mysql --connect-timeout=3 -N -B -e \ |
| 593 | "SELECT CONCAT(user,'@',host) FROM mysql.user |
| 594 | WHERE (authentication_string='' OR authentication_string IS NULL) |
| 595 | AND plugin NOT IN ('unix_socket','auth_socket','mysql_no_login') |
| 596 | AND account_locked='N';" 2>/dev/null | tr '\n' ' ') |
| 597 | [ -n "$(printf '%s' "$ohne" | tr -d ' ')" ] && add konten critical "Datenbank-Konto ohne Passwort" "Datenbank-Härtung" \ |
| 598 | "Diese Konten können sich ohne Passwort anmelden: $ohne. Wer den Datenbank-Port erreicht, hat damit vollen Zugriff auf die Daten." |
| 599 | |
| 600 | anon=$(mysql --connect-timeout=3 -N -B -e "SELECT COUNT(*) FROM mysql.user WHERE user='';" 2>/dev/null) |
| 601 | case "$anon" in ''|*[!0-9]*) anon=0 ;; esac |
| 602 | [ "$anon" -gt 0 ] && add konten critical "Anonymer Datenbank-Zugang vorhanden" "Datenbank-Härtung" \ |
| 603 | "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." |
| 604 | |
| 605 | jeder=$(mysql --connect-timeout=3 -N -B -e \ |
| 606 | "SELECT COUNT(*) FROM mysql.user WHERE user IN ('root','admin') AND host IN ('%','0.0.0.0');" 2>/dev/null) |
| 607 | case "$jeder" in ''|*[!0-9]*) jeder=0 ;; esac |
| 608 | [ "$jeder" -gt 0 ] && add konten critical "Datenbank-Administrator von überall erreichbar" "Datenbank-Härtung" \ |
| 609 | "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." |
| 610 | |
| 611 | test=$(mysql --connect-timeout=3 -N -B -e "SHOW DATABASES LIKE 'test';" 2>/dev/null) |
| 612 | [ -n "$test" ] && add dienste warn "Beispiel-Datenbank „test“ ist noch vorhanden" "Datenbank-Härtung" \ |
| 613 | "Die bei der Installation angelegte Datenbank „test“ existiert noch. Sie ist häufig für alle Konten beschreibbar und sollte entfernt werden." |
| 614 | } |
| 615 | |
| 616 | checks() { |
| 617 | local first=0; [ -f "$STATE/.initialized" ] || first=1 |
| 618 | |
| 619 | local uid0; uid0=$(awk -F: '$3==0{print $1}' /etc/passwd | sort | tr '\n' ' ') |
| 620 | if [ "$first" = 0 ] && [ "$uid0" != "$(baseline_get uid0)" ]; then |
| 621 | add konten critical "Neues Konto mit Root-Rechten gefunden" "Konten-Check" "UID-0-Konten jetzt: $uid0" |
| 622 | fi |
| 623 | baseline_set uid0 "$uid0" |
| 624 | |
| 625 | local users; users=$(cut -d: -f1 /etc/passwd | sort | tr '\n' ' ') |
| 626 | if [ "$first" = 0 ] && [ "$users" != "$(baseline_get users)" ]; then |
| 627 | add konten warn "Neues Benutzerkonto angelegt" "Konten-Check" |
| 628 | fi |
| 629 | baseline_set users "$users" |
| 630 | |
| 631 | if [ -f /root/.ssh/authorized_keys ]; then |
| 632 | local sk; sk=$(sha256sum /root/.ssh/authorized_keys | cut -d' ' -f1) |
| 633 | if [ "$first" = 0 ] && [ "$sk" != "$(baseline_get sshkeys)" ]; then |
| 634 | add konten warn "SSH-Schlüssel für root geändert" "SSH-Baseline" |
| 635 | fi |
| 636 | baseline_set sshkeys "$sk" |
| 637 | fi |
| 638 | |
| 639 | local p |
| 640 | for p in /proc/[0-9]*/exe; do |
| 641 | local tgt; tgt=$(readlink "$p" 2>/dev/null) || continue |
| 642 | case "$tgt" in |
| 643 | /tmp/*|/dev/shm/*|/var/tmp/*) |
| 644 | add prozesse critical "Verdächtiger Prozess aus temporärem Verzeichnis" "Prozess-Check" "$tgt" ;; |
| 645 | *"(deleted)") |
| 646 | # Ein System-Dienst hält nach einem Paket-Update oft noch die alte, gelöschte |
| 647 | # Programmdatei — das ist normal und wird vom Neustart-Hinweis abgedeckt. |
| 648 | # Kritisch nur, wenn die Programmdatei an einem ungewöhnlichen Ort lag. |
| 649 | case "$tgt" in |
| 650 | /home/*|/run/*|/var/tmp/*|/dev/shm/*) |
| 651 | add prozesse critical "Laufender Prozess ohne Programmdatei an ungewöhnlichem Ort" "Prozess-Check" "$tgt" ;; |
| 652 | esac ;; |
| 653 | esac |
| 654 | done |
| 655 | |
| 656 | if have ss; then |
| 657 | local ports; ports=$(ss -tlnH 2>/dev/null | awk '{print $4}' | sed 's/.*://' | sort -un | tr '\n' ' ') |
| 658 | local known; known=$(baseline_get ports) |
| 659 | [ -z "$(baseline_get allowed_ports)" ] && baseline_set allowed_ports "$ports" # Erlaubt-Liste fürs Inventar: beim Einlernen bzw. Erststart nach Update setzen, danach stabil |
| 660 | if [ "$first" = 0 ] && [ -n "$known" ]; then |
| 661 | local pp |
| 662 | for pp in $ports; do |
| 663 | case " $known " in *" $pp "*) : ;; *) net port warn "Neuer lauschender Port: $pp" ;; esac |
| 664 | done |
| 665 | fi |
| 666 | baseline_set ports "$ports" |
| 667 | fi |
| 668 | |
| 669 | # Laufende Dienste fürs Inventar einlernen (Erlaubt-Liste beim ersten Lauf, stabil bis rebaseline) |
| 670 | if have systemctl; then |
| 671 | 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' ' ') |
| 672 | [ -z "$(baseline_get allowed_svcs)" ] && baseline_set allowed_svcs "$svcs" |
| 673 | fi |
| 674 | |
| 675 | # Netzwerksicherheit: Firewall-Status + nach außen offene sensible Dienste |
| 676 | local fw_active=0 |
| 677 | have ufw && ufw status 2>/dev/null | grep -qi "Status: active" && fw_active=1 |
| 678 | [ "$fw_active" = 0 ] && have nft && nft list ruleset 2>/dev/null | grep -qiE 'policy drop|drop$|reject' && fw_active=1 |
| 679 | [ "$fw_active" = 0 ] && have iptables && iptables -S 2>/dev/null | grep -qiE '^-P INPUT DROP|-j DROP' && fw_active=1 |
| 680 | [ "$fw_active" = 0 ] && add dienste warn "Keine aktive Firewall erkannt — Empfehlung: Firewall-Baseline aktivieren, damit nur benötigte Ports erreichbar sind" "Netzwerksicherheit" |
| 681 | if have ss; then |
| 682 | local ex sens |
| 683 | ex=$(ss -tlnH 2>/dev/null | awk '$4 ~ /(0\.0\.0\.0|\[::\]|\*):/{print $4}' | sed 's/.*://' | sort -un) |
| 684 | for sens in $ex; do |
| 685 | case "$sens" in |
| 686 | 3306|5432|27017|6379|9200|11211|5984|9042) |
| 687 | DB_OFFEN=1 |
| 688 | 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" ;; |
| 689 | esac |
| 690 | done |
| 691 | fi |
| 692 | |
| 693 | [ -f /var/run/reboot-required ] && add updates warn "Neustart ausstehend (Kernel- oder Kernbibliotheks-Update)" "Gesundheit" |
| 694 | |
| 695 | if have apt-get; then |
| 696 | local sec; sec=$(apt-get -s -o Debug::NoLocking=true upgrade 2>/dev/null | grep -c '^Inst.*security' || true) |
| 697 | case "$sec" in ''|*[!0-9]*) sec=0 ;; esac |
| 698 | SEC_UPD=$sec |
| 699 | [ "$sec" -gt 0 ] && add updates warn "$sec offene Sicherheitsupdates" "Frische" |
| 700 | fi |
| 701 | |
| 702 | if [ -d /etc/wachhund/checks.d ]; then |
| 703 | local mod |
| 704 | for mod in /etc/wachhund/checks.d/*; do |
| 705 | [ -x "$mod" ] || continue |
| 706 | local out rc name |
| 707 | out=$("$mod" 2>/dev/null); rc=$? |
| 708 | name=$(grep -m1 '^# name:' "$mod" 2>/dev/null | sed 's/^# name:[[:space:]]*//') |
| 709 | [ -z "$name" ] && name=$(basename "$mod") |
| 710 | case "$rc" in |
| 711 | 1) add eigene warn "$name: ${out:-Warnung}" "Eigener Check" ;; |
| 712 | 2) add eigene critical "$name: ${out:-Kritisch}" "Eigener Check" ;; |
| 713 | esac |
| 714 | done |
| 715 | fi |
| 716 | |
| 717 | # Härtungs-Hinweise zu SSH-Zugang (Empfehlungen). Effektive Konfiguration prüfen, |
| 718 | # nicht nur die Datei — Standardwerte (z. B. PasswordAuthentication) greifen auch |
| 719 | # ohne explizite Zeile. sshd -T liefert den tatsächlich wirksamen Wert. |
| 720 | local sshcfg="" |
| 721 | have sshd && sshcfg=$(sshd -T 2>/dev/null) |
| 722 | [ -z "$sshcfg" ] && sshcfg=$(grep -ihE '^[[:space:]]*(PasswordAuthentication|PermitRootLogin)[[:space:]]' /etc/ssh/sshd_config 2>/dev/null | tr 'A-Z' 'a-z') |
| 723 | printf '%s\n' "$sshcfg" | grep -qiE '^[[:space:]]*passwordauthentication[[:space:]]+yes' \ |
| 724 | && add konten warn "SSH-Passwort-Login ist aktiv — Empfehlung: in der Sicherheits-Baseline auf reine Schlüssel-Anmeldung umstellen" "Härtung" |
| 725 | printf '%s\n' "$sshcfg" | grep -qiE '^[[:space:]]*permitrootlogin[[:space:]]+yes' \ |
| 726 | && add konten warn "Direkter Root-Login per Passwort ist erlaubt — Empfehlung: auf Schlüssel beschränken (Baseline)" "Härtung" |
| 727 | |
| 728 | services |
| 729 | certs |
| 730 | persistenz "$first" |
| 731 | kapazitaet |
| 732 | spurenwache "$first" |
| 733 | kernmodule "$first" |
| 734 | lebensende |
| 735 | dauerlast |
| 736 | ausgehend |
| 737 | zeitpruefung |
| 738 | db_haertung |
| 739 | # Teure Durchsuchungen des Dateisystems und SMART (weckt schlafende Platten) |
| 740 | # laufen nur beim taeglichen Rundgang. |
| 741 | if [ "${WH_DEEP:-0}" = 1 ]; then |
| 742 | disks |
| 743 | suidwache "$first" |
| 744 | webhaertung |
| 745 | fi |
| 746 | |
| 747 | haertungsgrad "$sshcfg" "$fw_active" |
| 748 | |
| 749 | touch "$STATE/.initialized" |
| 750 | } |
| 751 | |
| 752 | # Härtungsgrad. Bis 2.2.23 waren es drei Kriterien — damit landeten fast alle Server auf |
| 753 | # 90 oder 100 und der Wert sagte wenig. Jetzt elf gewichtete Kriterien, Summe genau 100, |
| 754 | # jedes einzeln ausgewiesen: das Dashboard kann so zeigen, WAS die fehlenden Punkte kostet, |
| 755 | # statt nur eine nackte Zahl. Schema-Nummer wird mitgesendet, damit die Zentrale nur Werte |
| 756 | # derselben Rechenweise miteinander vergleicht. |
| 757 | SCORE_SCHEMA=2 |
| 758 | haertungsgrad() { |
| 759 | local sshcfg="$1" fw="$2" |
| 760 | local score=0 |
| 761 | |
| 762 | # punkt <schlüssel> <gewicht> <erfüllt 0|1> |
| 763 | punkt() { |
| 764 | [ "$3" = 1 ] && score=$((score + $2)) |
| 765 | [ -n "${HRT:-}" ] && printf '%s\t%s\t%s\n' "$1" "$2" "$3" >> "$HRT" |
| 766 | } |
| 767 | |
| 768 | local ok |
| 769 | ok=1; printf '%s\n' "$sshcfg" | grep -qiE '^[[:space:]]*passwordauthentication[[:space:]]+yes' && ok=0 |
| 770 | punkt ssh_nopass 15 "$ok" |
| 771 | |
| 772 | ok=1; printf '%s\n' "$sshcfg" | grep -qiE '^[[:space:]]*permitrootlogin[[:space:]]+yes' && ok=0 |
| 773 | punkt ssh_noroot 10 "$ok" |
| 774 | |
| 775 | punkt firewall 13 "$fw" |
| 776 | |
| 777 | # Automatische Sicherheitsupdates: Debian/Ubuntu über unattended-upgrades, RHEL-Familie |
| 778 | # über dnf-automatic. Der Timer allein genügt nicht — er muss auch aktiviert sein. |
| 779 | ok=0 |
| 780 | if [ -f /etc/apt/apt.conf.d/20auto-upgrades ] && |
| 781 | grep -qE 'Unattended-Upgrade[^;]*"1"' /etc/apt/apt.conf.d/20auto-upgrades 2>/dev/null; then ok=1; fi |
| 782 | [ "$ok" = 0 ] && have systemctl && systemctl is-enabled dnf-automatic.timer >/dev/null 2>&1 && ok=1 |
| 783 | punkt autoupdate 10 "$ok" |
| 784 | |
| 785 | ok=1; [ "${SEC_UPD:-0}" -gt 0 ] && ok=0 |
| 786 | punkt updates 12 "$ok" |
| 787 | |
| 788 | ok=1; [ -f /var/run/reboot-required ] && ok=0 |
| 789 | punkt reboot 5 "$ok" |
| 790 | |
| 791 | ok=0 |
| 792 | have systemctl && { systemctl is-active fail2ban >/dev/null 2>&1 || systemctl is-active sshguard >/dev/null 2>&1; } && ok=1 |
| 793 | punkt bruteforce 10 "$ok" |
| 794 | |
| 795 | ok=1; [ "${DB_OFFEN:-0}" = 1 ] && ok=0 |
| 796 | punkt dbzu 8 "$ok" |
| 797 | |
| 798 | # Erst das überschrittene Lebensende zählt als Mangel: solange noch Updates kommen, ist |
| 799 | # das System härtbar. Auf das nahende Ende weist ohnehin ein eigener Befund hin. Teil- |
| 800 | # punkte wären hier falsch — dann ginge die Rechnung „100 minus offene Punkte" nicht auf. |
| 801 | ok=1; [ "${EOL_ST:-0}" = 2 ] && ok=0 |
| 802 | punkt eol 8 "$ok" |
| 803 | |
| 804 | ok=1 |
| 805 | [ "${DISK_TOTAL:-0}" -gt 0 ] && [ $(( 100 * ${DISK_USED:-0} / DISK_TOTAL )) -ge 90 ] && ok=0 |
| 806 | punkt platte 5 "$ok" |
| 807 | |
| 808 | ok="${ZEIT_OK:-1}" |
| 809 | punkt zeit 4 "$ok" |
| 810 | |
| 811 | unset -f punkt |
| 812 | [ "$score" -lt 0 ] && score=0 |
| 813 | [ "$score" -gt 100 ] && score=100 |
| 814 | HARDENING=$score |
| 815 | } |
| 816 | |
| 817 | # Bestandsaufnahme fürs Dashboard: aktuell lauschende Ports und laufende Dienste, jeweils mit |
| 818 | # Marker, ob sie zum eingelernten Normalzustand gehören (1=erlaubt) oder neu sind (0). Rein |
| 819 | # beschreibend — ändert nichts. Begrenzt, damit die Übertragung schlank bleibt. |
| 820 | inventory() { |
| 821 | [ -n "${INV:-}" ] || return 0 |
| 822 | local allowed_p allowed_s |
| 823 | allowed_p=" $(baseline_get allowed_ports) " |
| 824 | allowed_s=" $(baseline_get allowed_svcs) " |
| 825 | if have ss; then |
| 826 | ss -tlnpH 2>/dev/null | while IFS= read -r line; do |
| 827 | local addr port proc |
| 828 | addr=$(printf '%s' "$line" | awk '{print $4}') |
| 829 | port=${addr##*:} |
| 830 | case "$port" in ''|*[!0-9]*) continue ;; esac |
| 831 | proc=$(printf '%s' "$line" | grep -oE 'users:\(\("[^"]+' | sed 's/.*"//' | head -1) |
| 832 | case "$allowed_p" in |
| 833 | *" $port "*) printf 'port\t%s\t1\t%s\n' "$port" "${proc:-Dienst}" >> "$INV" ;; |
| 834 | *) printf 'port\t%s\t0\t%s\n' "$port" "${proc:-Dienst}" >> "$INV" ;; |
| 835 | esac |
| 836 | done |
| 837 | fi |
| 838 | if have systemctl; then |
| 839 | systemctl list-units --type=service --state=running --no-legend --plain 2>/dev/null \ |
| 840 | | awk '{print $1}' | sed 's/\.service$//' | head -80 | while IFS= read -r svc; do |
| 841 | [ -n "$svc" ] || continue |
| 842 | case "$allowed_s" in |
| 843 | *" $svc "*) printf 'dienst\t%s\t1\t\n' "$svc" >> "$INV" ;; |
| 844 | *) printf 'dienst\t%s\t0\t\n' "$svc" >> "$INV" ;; |
| 845 | esac |
| 846 | done |
| 847 | fi |
| 848 | } |
| 849 | |
| 850 | # Verlauf beobachten (rein lesend): Änderungen an sicherheitskritischen Dateien, am Passwort- |
| 851 | # Speicher und an SSH-Schlüsseln. Meldet NUR, DASS sich etwas geändert hat — nie Inhalte. |
| 852 | activity_scan() { |
| 853 | [ -n "${ACT:-}" ] || return 0 |
| 854 | local first=0; [ -f "$STATE/.act_init" ] || first=1 |
| 855 | if [ -f /etc/shadow ]; then |
| 856 | local sm; sm=$(stat -c %Y /etc/shadow 2>/dev/null) |
| 857 | if [ "$first" = 0 ] && [ -n "$sm" ] && [ "$sm" != "$(baseline_get act_shadow)" ]; then |
| 858 | printf 'passwort\tEin Konto-Passwort wurde gesetzt oder geändert (%s)\n' "$(date -d @"$sm" '+%d.%m.%Y %H:%M' 2>/dev/null)" >> "$ACT" |
| 859 | fi |
| 860 | baseline_set act_shadow "$sm" |
| 861 | fi |
| 862 | 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) |
| 863 | if [ "$first" = 0 ] && [ -n "$akhash" ] && [ "$akhash" != "$(baseline_get act_keys)" ]; then |
| 864 | printf 'sshkey\tEin SSH-Schlüssel wurde hinterlegt oder geändert (%s)\n' "$(date '+%d.%m.%Y %H:%M')" >> "$ACT" |
| 865 | fi |
| 866 | baseline_set act_keys "$akhash" |
| 867 | local f n m |
| 868 | for f in /etc/passwd /etc/group /etc/sudoers /etc/ssh/sshd_config /etc/crontab /etc/hosts; do |
| 869 | [ -f "$f" ] || continue |
| 870 | n=$(printf '%s' "$f" | tr '/.' '__') |
| 871 | m=$(stat -c %Y "$f" 2>/dev/null) |
| 872 | if [ "$first" = 0 ] && [ -n "$m" ] && [ "$m" != "$(baseline_get "act_$n")" ]; then |
| 873 | printf 'datei\t%s wurde bearbeitet (%s)\n' "$f" "$(date -d @"$m" '+%d.%m.%Y %H:%M' 2>/dev/null)" >> "$ACT" |
| 874 | fi |
| 875 | baseline_set "act_$n" "$m" |
| 876 | done |
| 877 | touch "$STATE/.act_init" |
| 878 | } |
| 879 | |
| 880 | # --- Baseline-Härtung (fest einprogrammiert, abgesichert) -------------------- |
| 881 | |
| 882 | ssh_reload() { systemctl reload ssh 2>/dev/null || systemctl reload sshd 2>/dev/null || service ssh reload 2>/dev/null || true; } |
| 883 | 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}"; } |
| 884 | |
| 885 | baseline_ssh_nopass() { |
| 886 | grep -qiE '^\s*PasswordAuthentication\s+no' /etc/ssh/sshd_config 2>/dev/null && return 0 |
| 887 | local haskey=0 kf |
| 888 | for kf in /root/.ssh/authorized_keys /home/*/.ssh/authorized_keys; do |
| 889 | [ -s "$kf" ] && haskey=1 && break |
| 890 | done |
| 891 | if [ "$haskey" = 0 ]; then |
| 892 | add konten warn "SSH-Passwort-Login NICHT deaktiviert: kein Schlüssel hinterlegt (Schutz vor Aussperren)" "Baseline" |
| 893 | return 0 |
| 894 | fi |
| 895 | if grep -qiE '^\s*#?\s*PasswordAuthentication' /etc/ssh/sshd_config; then |
| 896 | sed -i 's/^[[:space:]]*#\?[[:space:]]*PasswordAuthentication.*/PasswordAuthentication no/I' /etc/ssh/sshd_config |
| 897 | else |
| 898 | echo 'PasswordAuthentication no' >> /etc/ssh/sshd_config |
| 899 | fi |
| 900 | ssh_reload |
| 901 | add konten info "SSH-Passwort-Login durch Wachhund-Baseline deaktiviert" "Baseline" |
| 902 | } |
| 903 | |
| 904 | baseline_ssh_noroot() { |
| 905 | grep -qiE '^\s*PermitRootLogin\s+(prohibit-password|no)' /etc/ssh/sshd_config 2>/dev/null && return 0 |
| 906 | if grep -qiE '^\s*#?\s*PermitRootLogin' /etc/ssh/sshd_config; then |
| 907 | sed -i 's/^[[:space:]]*#\?[[:space:]]*PermitRootLogin.*/PermitRootLogin prohibit-password/I' /etc/ssh/sshd_config |
| 908 | else |
| 909 | echo 'PermitRootLogin prohibit-password' >> /etc/ssh/sshd_config |
| 910 | fi |
| 911 | ssh_reload |
| 912 | add konten info "Direkter Root-Login durch Wachhund-Baseline auf Schlüssel beschränkt" "Baseline" |
| 913 | } |
| 914 | |
| 915 | baseline_firewall() { |
| 916 | have ufw || return 0 |
| 917 | ufw status 2>/dev/null | grep -qi "Status: active" && return 0 |
| 918 | local sp; sp=$(ssh_port) |
| 919 | ufw allow "$sp"/tcp >/dev/null 2>&1 |
| 920 | ufw --force enable >/dev/null 2>&1 |
| 921 | add updates info "Firewall (ufw) durch Wachhund-Baseline aktiviert (SSH-Port $sp offen gehalten)" "Baseline" |
| 922 | } |
| 923 | |
| 924 | baseline_autoupdates() { |
| 925 | have apt-get || return 0 |
| 926 | [ -f /etc/apt/apt.conf.d/20auto-upgrades ] && grep -q 'Unattended-Upgrade "1"' /etc/apt/apt.conf.d/20auto-upgrades && return 0 |
| 927 | DEBIAN_FRONTEND=noninteractive apt-get install -y -qq unattended-upgrades >/dev/null 2>&1 |
| 928 | printf 'APT::Periodic::Update-Package-Lists "1";\nAPT::Periodic::Unattended-Upgrade "1";\n' > /etc/apt/apt.conf.d/20auto-upgrades |
| 929 | add updates info "Automatische Sicherheitsupdates durch Wachhund-Baseline aktiviert" "Baseline" |
| 930 | } |
| 931 | |
| 932 | apply_baseline() { |
| 933 | local flags f; flags=$(cat "$STATE/baseline" 2>/dev/null) || return 0 |
| 934 | [ -n "$flags" ] || return 0 |
| 935 | for f in $flags; do |
| 936 | case "$f" in |
| 937 | ssh_nopass) baseline_ssh_nopass ;; |
| 938 | ssh_noroot) baseline_ssh_noroot ;; |
| 939 | firewall) baseline_firewall ;; |
| 940 | autoupdates) baseline_autoupdates ;; |
| 941 | esac |
| 942 | done |
| 943 | } |
| 944 | |
| 945 | # --- Tool-Store (fest einprogrammiert) --------------------------------------- |
| 946 | |
| 947 | apt_install() { |
| 948 | # Nur Pakete vormerken, die WIR neu installieren — vorher vorhandene bleiben beim Widerruf unberührt. |
| 949 | if ! dpkg -s "$1" >/dev/null 2>&1; then |
| 950 | grep -qxF "$1" "$STATE/installed_pkgs" 2>/dev/null || echo "$1" >> "$STATE/installed_pkgs" |
| 951 | fi |
| 952 | DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "$1" >/dev/null 2>&1 |
| 953 | } |
| 954 | 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 ]; } |
| 955 | mark_run() { date +%s > "$STATE/tool_$1_ts"; } |
| 956 | |
| 957 | tool_fail2ban() { |
| 958 | have fail2ban-client || apt_install fail2ban |
| 959 | if have fail2ban-client; then |
| 960 | # Einstellungen aus dem Panel anwenden (Fehlversuche, Sperrdauer) |
| 961 | local mr bt |
| 962 | mr=$(grep -m1 '^maxretry=' "$STATE/toolset_fail2ban" 2>/dev/null | cut -d= -f2) |
| 963 | bt=$(grep -m1 '^bantime=' "$STATE/toolset_fail2ban" 2>/dev/null | cut -d= -f2) |
| 964 | if [ -n "$mr" ] || [ -n "$bt" ]; then |
| 965 | { echo "[sshd]"; echo "enabled = true" |
| 966 | [ -n "$mr" ] && echo "maxretry = $mr" |
| 967 | [ -n "$bt" ] && echo "bantime = ${bt}m"; } > /etc/fail2ban/jail.d/wachhund.local |
| 968 | systemctl reload fail2ban >/dev/null 2>&1 || systemctl restart fail2ban >/dev/null 2>&1 |
| 969 | fi |
| 970 | systemctl enable --now fail2ban >/dev/null 2>&1 |
| 971 | if systemctl is-active fail2ban >/dev/null 2>&1; then |
| 972 | local n; n=$(fail2ban-client status sshd 2>/dev/null | grep -i 'Currently banned' | grep -oE '[0-9]+' | head -1) |
| 973 | tstat fail2ban 1 ok "aktiv, ${n:-0} IP(s) gesperrt" |
| 974 | 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 |
| 975 | else tstat fail2ban 0 fehler "Installation fehlgeschlagen"; fi |
| 976 | } |
| 977 | |
| 978 | tool_auditd() { |
| 979 | have auditctl || apt_install auditd |
| 980 | if have auditctl; then |
| 981 | systemctl enable --now auditd >/dev/null 2>&1 |
| 982 | # kritische Pfade in Echtzeit überwachen (idempotent) |
| 983 | auditctl -w /etc/passwd -p wa -k wh_passwd 2>/dev/null |
| 984 | auditctl -w /etc/ssh/sshd_config -p wa -k wh_ssh 2>/dev/null |
| 985 | auditctl -w /etc/sudoers -p wa -k wh_sudo 2>/dev/null |
| 986 | if systemctl is-active auditd >/dev/null 2>&1; then |
| 987 | local n; n=$(ausearch -k wh_passwd -k wh_ssh -k wh_sudo -ts today 2>/dev/null | grep -c '^type=' || true) |
| 988 | tstat auditd 1 ok "Echtzeit-Audit aktiv, ${n:-0} Ereignis(se) heute" |
| 989 | 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 |
| 990 | else tstat auditd 0 fehler "Installation fehlgeschlagen"; fi |
| 991 | } |
| 992 | |
| 993 | tool_rkhunter() { |
| 994 | have rkhunter || apt_install rkhunter |
| 995 | if ! have rkhunter; then tstat rkhunter 0 fehler "Installation fehlgeschlagen"; return; fi |
| 996 | if throttled rkhunter; then |
| 997 | rkhunter --propupd --quiet >/dev/null 2>&1 |
| 998 | rkhunter --check --sk --nocolors --rwo >/tmp/wh_rk 2>/dev/null |
| 999 | mark_run rkhunter |
| 1000 | local w; w=$(grep -c . /tmp/wh_rk 2>/dev/null || true) |
| 1001 | if [ "${w:-0}" -gt 0 ]; then |
| 1002 | tstat rkhunter 1 warnung "$w Warnung(en) im letzten Scan" "$(head -c 400 /tmp/wh_rk)" |
| 1003 | add einbruch warn "rkhunter meldet $w Warnung(en)" "rkhunter" "$(head -c 300 /tmp/wh_rk)" |
| 1004 | else tstat rkhunter 1 ok "keine Auffälligkeiten"; fi |
| 1005 | rm -f /tmp/wh_rk |
| 1006 | else tstat rkhunter 1 ok "installiert (Tiefenscan läuft täglich)"; fi |
| 1007 | } |
| 1008 | |
| 1009 | tool_chkrootkit() { |
| 1010 | have chkrootkit || apt_install chkrootkit |
| 1011 | if ! have chkrootkit; then tstat chkrootkit 0 fehler "Installation fehlgeschlagen"; return; fi |
| 1012 | if throttled chkrootkit; then |
| 1013 | local inf; inf=$(chkrootkit -q 2>/dev/null | grep -iE 'INFECTED' | head -3) |
| 1014 | mark_run chkrootkit |
| 1015 | if [ -n "$inf" ]; then |
| 1016 | tstat chkrootkit 1 warnung "mögliche Funde" "$inf" |
| 1017 | add einbruch critical "chkrootkit: mögliche Infektion" "chkrootkit" "$inf" |
| 1018 | else tstat chkrootkit 1 ok "keine Auffälligkeiten"; fi |
| 1019 | else tstat chkrootkit 1 ok "installiert (Scan läuft täglich)"; fi |
| 1020 | } |
| 1021 | |
| 1022 | tool_lynis() { |
| 1023 | have lynis || apt_install lynis |
| 1024 | if ! have lynis; then tstat lynis 0 fehler "Installation fehlgeschlagen"; return; fi |
| 1025 | if throttled lynis; then |
| 1026 | lynis audit system --quiet --no-colors >/dev/null 2>&1 |
| 1027 | mark_run lynis |
| 1028 | local idx; idx=$(grep -i 'hardening_index' /var/log/lynis.log 2>/dev/null | tail -1 | grep -oE '[0-9]+' | head -1) |
| 1029 | 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“)." |
| 1030 | else |
| 1031 | local idx; idx=$(grep -i 'hardening_index' /var/log/lynis.log 2>/dev/null | tail -1 | grep -oE '[0-9]+' | head -1) |
| 1032 | tstat lynis 1 ok "installiert${idx:+, Index $idx/100} (Audit läuft täglich)" |
| 1033 | fi |
| 1034 | } |
| 1035 | |
| 1036 | tool_clamav() { |
| 1037 | have clamscan || apt_install clamav |
| 1038 | if ! have clamscan; then tstat clamav 0 fehler "Installation fehlgeschlagen"; return; fi |
| 1039 | if throttled clamav; then |
| 1040 | have freshclam && freshclam --quiet >/dev/null 2>&1 |
| 1041 | local hits; hits=$(clamscan -r -i --no-summary /home /tmp /var/tmp 2>/dev/null | grep -c 'FOUND' || true) |
| 1042 | mark_run clamav |
| 1043 | if [ "${hits:-0}" -gt 0 ]; then |
| 1044 | tstat clamav 1 warnung "$hits Fund(e)" |
| 1045 | add einbruch critical "ClamAV: $hits Schadsoftware-Fund(e)" "ClamAV" |
| 1046 | else tstat clamav 1 ok "keine Funde"; fi |
| 1047 | else tstat clamav 1 ok "installiert (Scan läuft täglich)"; fi |
| 1048 | } |
| 1049 | |
| 1050 | tool_aide() { |
| 1051 | have aide || apt_install aide |
| 1052 | if ! have aide; then tstat aide 0 fehler "Installation fehlgeschlagen"; return; fi |
| 1053 | if [ ! -f /var/lib/aide/aide.db ]; then |
| 1054 | aideinit -y -f >/dev/null 2>&1 || aide --init >/dev/null 2>&1 |
| 1055 | [ -f /var/lib/aide/aide.db.new ] && mv /var/lib/aide/aide.db.new /var/lib/aide/aide.db |
| 1056 | tstat aide 1 ok "Integritäts-Datenbank angelegt" |
| 1057 | mark_run aide |
| 1058 | elif throttled aide; then |
| 1059 | local ch; ch=$(aide --check 2>/dev/null | grep -iE 'entries changed|added|removed' | head -1) |
| 1060 | mark_run aide |
| 1061 | if echo "$ch" | grep -qiE '[1-9]'; then |
| 1062 | tstat aide 1 warnung "Änderungen erkannt" "$ch" |
| 1063 | add einbruch warn "AIDE meldet Änderungen an Systemdateien" "AIDE" "$ch" |
| 1064 | else tstat aide 1 ok "keine Änderungen"; fi |
| 1065 | else tstat aide 1 ok "aktiv (Prüfung läuft täglich)"; fi |
| 1066 | } |
| 1067 | |
| 1068 | tool_debsums() { |
| 1069 | have debsums || apt_install debsums |
| 1070 | if ! have debsums; then tstat debsums 0 fehler "auf diesem System nicht verfügbar"; return; fi |
| 1071 | if throttled debsums; then |
| 1072 | local ch; ch=$(debsums -s 2>&1 | head -5) |
| 1073 | mark_run debsums |
| 1074 | if [ -n "$ch" ]; then |
| 1075 | tstat debsums 1 warnung "veränderte Paketdateien" "$ch" |
| 1076 | add einbruch warn "debsums: veränderte Systemdateien erkannt" "debsums" "$ch" |
| 1077 | else tstat debsums 1 ok "alle Paketdateien unverändert"; fi |
| 1078 | else tstat debsums 1 ok "aktiv (Prüfung läuft täglich)"; fi |
| 1079 | } |
| 1080 | |
| 1081 | tool_arpwatch() { |
| 1082 | have arpwatch || apt_install arpwatch |
| 1083 | if have arpwatch; then |
| 1084 | systemctl enable --now arpwatch >/dev/null 2>&1 || true |
| 1085 | tstat arpwatch 1 ok "überwacht das Netz auf ARP-Spoofing" |
| 1086 | else tstat arpwatch 0 fehler "auf diesem System nicht verfügbar"; fi |
| 1087 | } |
| 1088 | |
| 1089 | tool_psad() { |
| 1090 | have psad || apt_install psad |
| 1091 | if ! have psad; then tstat psad 0 fehler "auf diesem System nicht verfügbar"; return; fi |
| 1092 | systemctl enable --now psad >/dev/null 2>&1 || true |
| 1093 | if throttled psad; then psad --sig-update >/dev/null 2>&1 || true; mark_run psad; fi |
| 1094 | if systemctl is-active psad >/dev/null 2>&1; then |
| 1095 | local n; n=$(psad --Status 2>/dev/null | grep -iE 'scan sources' | grep -oE '[0-9]+' | head -1) |
| 1096 | if [ -n "$n" ] && [ "$n" -gt 0 ]; then |
| 1097 | local det; det=$(psad --Status 2>/dev/null | grep -iE 'src:|Source|DL:|packets|signature match' | head -12) |
| 1098 | tstat psad 1 warnung "$n Portscan-Quelle(n) erkannt" "${det:-Details siehe psad --Status auf dem Server}" |
| 1099 | add prozesse warn "psad meldet $n Portscan-Quelle(n) gegen diesen Server" "psad" "$det" |
| 1100 | else tstat psad 1 ok "überwacht auf Portscans"; fi |
| 1101 | 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 |
| 1102 | } |
| 1103 | |
| 1104 | tool_needrestart() { |
| 1105 | have needrestart || apt_install needrestart |
| 1106 | if ! have needrestart; then tstat needrestart 0 fehler "auf diesem System nicht verfügbar"; return; fi |
| 1107 | local svclist svc; svclist=$(needrestart -b 2>/dev/null | grep 'NEEDRESTART-SVC' | sed 's/.*:[[:space:]]*//' | sort -u | tr '\n' ' ') |
| 1108 | svc=$(printf '%s' "$svclist" | wc -w | tr -d ' ') |
| 1109 | if [ "${svc:-0}" -gt 0 ]; then |
| 1110 | tstat needrestart 1 warnung "$svc Dienst(e) brauchen Neustart" "Betroffen: $svclist" |
| 1111 | add updates warn "$svc Dienst(e) laufen mit veralteten Bibliotheken (Neustart nötig)" "needrestart" "Betroffene Dienste: $svclist" |
| 1112 | else tstat needrestart 1 ok "keine veralteten Dienste"; fi |
| 1113 | } |
| 1114 | |
| 1115 | tool_acct() { |
| 1116 | have lastcomm || apt_install acct |
| 1117 | if have lastcomm; then |
| 1118 | systemctl enable --now acct >/dev/null 2>&1 || systemctl enable --now psacct >/dev/null 2>&1 || true |
| 1119 | tstat acct 1 ok "protokolliert ausgeführte Befehle" |
| 1120 | else tstat acct 0 fehler "auf diesem System nicht verfügbar"; fi |
| 1121 | } |
| 1122 | |
| 1123 | tool_unhide() { |
| 1124 | have unhide || apt_install unhide |
| 1125 | if ! have unhide; then tstat unhide 0 fehler "auf diesem System nicht verfügbar"; return; fi |
| 1126 | if throttled unhide; then |
| 1127 | local h; h=$(unhide quick 2>/dev/null | grep -iE 'Found HIDDEN' | head -3) |
| 1128 | mark_run unhide |
| 1129 | if [ -n "$h" ]; then |
| 1130 | tstat unhide 1 warnung "versteckte Prozesse gefunden" "$h" |
| 1131 | add einbruch critical "unhide: versteckte Prozesse erkannt" "unhide" "$h" |
| 1132 | else tstat unhide 1 ok "keine versteckten Prozesse"; fi |
| 1133 | else tstat unhide 1 ok "installiert (Prüfung läuft täglich)"; fi |
| 1134 | } |
| 1135 | |
| 1136 | tool_logwatch() { |
| 1137 | have logwatch || apt_install logwatch |
| 1138 | if have logwatch; then |
| 1139 | tstat logwatch 1 ok "täglicher Log-Bericht aktiv" |
| 1140 | else tstat logwatch 0 fehler "auf diesem System nicht verfügbar"; fi |
| 1141 | } |
| 1142 | |
| 1143 | tool_apparmor() { |
| 1144 | have aa-status || apt_install apparmor |
| 1145 | have aa-status && ! have aa-enforce && apt_install apparmor-utils |
| 1146 | if have aa-status; then |
| 1147 | systemctl enable --now apparmor >/dev/null 2>&1 || true |
| 1148 | local n; n=$(aa-status --profiled 2>/dev/null | head -1 || echo 0) |
| 1149 | tstat apparmor 1 ok "${n:-0} Profil(e) aktiv" |
| 1150 | else tstat apparmor 0 fehler "auf diesem System nicht verfügbar"; fi |
| 1151 | } |
| 1152 | |
| 1153 | manage_tools() { |
| 1154 | local tools t hb; tools=$(cat "$STATE/tools" 2>/dev/null) || return 0 |
| 1155 | [ -n "$tools" ] || return 0 |
| 1156 | : > "$STATE/toolstat" |
| 1157 | rm -f "$STATE/tool_progress" |
| 1158 | # Welche aktivierten Tools sind noch nicht installiert? (Binary-Prüfung je Tool) |
| 1159 | local pending="" |
| 1160 | for t in $tools; do |
| 1161 | case "$t" in |
| 1162 | fail2ban) hb=fail2ban-client;; auditd) hb=auditctl;; rkhunter) hb=rkhunter;; |
| 1163 | chkrootkit) hb=chkrootkit;; lynis) hb=lynis;; clamav) hb=clamscan;; |
| 1164 | aide) hb=aide;; debsums) hb=debsums;; arpwatch) hb=arpwatch;; psad) hb=psad;; |
| 1165 | needrestart) hb=needrestart;; acct) hb=lastcomm;; unhide) hb=unhide;; |
| 1166 | logwatch) hb=logwatch;; apparmor) hb=aa-status;; *) continue;; |
| 1167 | esac |
| 1168 | have "$hb" || pending="$pending $t" |
| 1169 | done |
| 1170 | # Fortschritt: pro Kontakt höchstens EIN neues Tool einrichten. Mit dem Schnell-Takt läuft |
| 1171 | # der Fortschritt Schritt für Schritt im Dashboard durch, statt in einem langen Rutsch. |
| 1172 | local install_now="" remaining=0 |
| 1173 | if [ -n "$pending" ]; then |
| 1174 | install_now=$(echo $pending | awk '{print $1}') |
| 1175 | remaining=$(echo $pending | wc -w | tr -d ' ') |
| 1176 | fi |
| 1177 | for t in $tools; do |
| 1178 | declare -F "tool_$t" >/dev/null 2>&1 || continue |
| 1179 | case " $pending " in |
| 1180 | *" $t "*) if [ "$t" = "$install_now" ]; then "tool_$t"; else tstat "$t" 0 laeuft "wird demnächst eingerichtet"; fi ;; |
| 1181 | *) "tool_$t" ;; |
| 1182 | esac |
| 1183 | done |
| 1184 | [ -n "$install_now" ] && printf 'setup\t%s\t%s\n' "$install_now" "$remaining" > "$STATE/tool_progress" |
| 1185 | } |
| 1186 | |
| 1187 | # Hält die aktivierten Sicherheitstools und ihre Signatur-Datenbanken aktuell. |
| 1188 | # Läuft im täglichen Tiefenscan (nicht im Minuten-Herzschlag). |
| 1189 | update_tools() { |
| 1190 | have apt-get || return 0 |
| 1191 | local tools; tools=$(cat "$STATE/tools" 2>/dev/null); [ -n "$tools" ] || return 0 |
| 1192 | local pkgs="" t |
| 1193 | for t in $tools; do |
| 1194 | case "$t" in |
| 1195 | fail2ban) pkgs="$pkgs fail2ban";; rkhunter) pkgs="$pkgs rkhunter";; |
| 1196 | chkrootkit) pkgs="$pkgs chkrootkit";; lynis) pkgs="$pkgs lynis";; |
| 1197 | clamav) pkgs="$pkgs clamav";; auditd) pkgs="$pkgs auditd";; |
| 1198 | aide) pkgs="$pkgs aide";; debsums) pkgs="$pkgs debsums";; |
| 1199 | arpwatch) pkgs="$pkgs arpwatch";; needrestart) pkgs="$pkgs needrestart";; |
| 1200 | psad) pkgs="$pkgs psad";; |
| 1201 | acct) pkgs="$pkgs acct";; unhide) pkgs="$pkgs unhide";; |
| 1202 | logwatch) pkgs="$pkgs logwatch";; apparmor) pkgs="$pkgs apparmor";; |
| 1203 | esac |
| 1204 | done |
| 1205 | [ -n "$pkgs" ] || return 0 |
| 1206 | apt-get update -qq >/dev/null 2>&1 |
| 1207 | local up; up=$(DEBIAN_FRONTEND=noninteractive apt-get install --only-upgrade -y -qq $pkgs 2>&1 | grep -ciE '^(Setting up|Unpacking) ' || true) |
| 1208 | [ "${up:-0}" -gt 0 ] && addp updates info "$up Sicherheitstool-Aktualisierung(en) automatisch eingespielt" "Tool-Update" |
| 1209 | # Signatur-/Regel-Datenbanken frisch halten |
| 1210 | have rkhunter && rkhunter --update --quiet >/dev/null 2>&1 |
| 1211 | have freshclam && freshclam --quiet >/dev/null 2>&1 |
| 1212 | return 0 |
| 1213 | } |
| 1214 | |
| 1215 | # AbuseTrack: Angreifer-IPs melden + Community-Blocklist ziehen und sperren. |
| 1216 | # Läuft im täglichen Tiefenscan. Key/Flags kommen als Config vom Panel. |
| 1217 | abusetrack_run() { |
| 1218 | local key flags; key=$(cat "$STATE/at_key" 2>/dev/null); flags=" $(cat "$STATE/at_flags" 2>/dev/null) " |
| 1219 | [ -n "$key" ] || return 0 |
| 1220 | have curl || return 0 |
| 1221 | |
| 1222 | # 1) Melden: aktuell gesperrte IPs (fail2ban) an AbuseTrack, jede nur einmal |
| 1223 | if [ "$flags" != "${flags#* report }" ] && have fail2ban-client; then |
| 1224 | local jail ip rep=0 |
| 1225 | for jail in $(fail2ban-client status 2>/dev/null | sed -n 's/.*Jail list:[[:space:]]*//p' | tr ',' ' '); do |
| 1226 | for ip in $(fail2ban-client status "$jail" 2>/dev/null | sed -n 's/.*Banned IP list:[[:space:]]*//p'); do |
| 1227 | echo "$ip" | grep -qE '^(10\.|127\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)' && continue |
| 1228 | grep -qxF "$ip" "$STATE/at_reported" 2>/dev/null && continue |
| 1229 | curl -s -o /dev/null --max-time 8 -X POST "https://abusetrack.io/api/v1/reports" \ |
| 1230 | -H "Authorization: Bearer $key" -H "Content-Type: application/json" \ |
| 1231 | -d "{\"type\":\"ip\",\"value\":\"$ip\",\"threatType\":\"brute-force\",\"comment\":\"Wiederholte fehlgeschlagene Anmeldungen, gemeldet vom Wachhund-Agenten\"}" \ |
| 1232 | && { echo "$ip" >> "$STATE/at_reported"; rep=$((rep+1)); } |
| 1233 | done |
| 1234 | done |
| 1235 | [ "$rep" -gt 0 ] && addp prozesse info "$rep Angreifer-IP(s) an AbuseTrack gemeldet" "AbuseTrack" |
| 1236 | fi |
| 1237 | |
| 1238 | # 2) Ziehen + Bannen: Community-Blocklist per ipset/iptables (nur bei aktiver Verbindung geschützt) |
| 1239 | if [ "$flags" != "${flags#* block }" ]; then |
| 1240 | have ipset || apt_install ipset |
| 1241 | have iptables || apt_install iptables |
| 1242 | if ! have ipset || ! have iptables; then |
| 1243 | addp prozesse warn "AbuseTrack-Sperrliste: ipset/iptables nicht verfügbar" "AbuseTrack"; return 0 |
| 1244 | fi |
| 1245 | ipset create wachhund_abuse hash:ip -exist 2>/dev/null |
| 1246 | # eigene Kette: aktive Verbindungen NIE kappen (Schutz vor Selbst-Aussperren), nur neue Verbindungen sperren |
| 1247 | if ! iptables -C INPUT -j wh_abuse 2>/dev/null; then |
| 1248 | iptables -N wh_abuse 2>/dev/null |
| 1249 | iptables -F wh_abuse 2>/dev/null |
| 1250 | iptables -A wh_abuse -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN 2>/dev/null |
| 1251 | iptables -A wh_abuse -m set --match-set wachhund_abuse src -j DROP 2>/dev/null |
| 1252 | iptables -I INPUT 1 -j wh_abuse 2>/dev/null |
| 1253 | fi |
| 1254 | local list n=0 tmp score |
| 1255 | score=$(cat "$STATE/at_score" 2>/dev/null); case "$score" in ''|*[!0-9]*) score=25 ;; esac |
| 1256 | # Mindest-Score der Community-Blockliste ist über den Parameter "score" steuerbar |
| 1257 | # (höher = weniger, aber höher bewertete Angreifer-IPs). Standard 25 (breitester Stand). |
| 1258 | list=$(curl -s --max-time 25 -H "Authorization: Bearer $key" "https://abusetrack.io/api/v1/blocklist.txt?score=$score" 2>/dev/null) |
| 1259 | if [ -n "$list" ]; then |
| 1260 | tmp=$(mktemp); echo "$list" | grep -oE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' > "$tmp" |
| 1261 | n=$(grep -c . "$tmp") |
| 1262 | ipset flush wachhund_abuse 2>/dev/null |
| 1263 | while IFS= read -r ip; do ipset add wachhund_abuse "$ip" -exist 2>/dev/null; done < "$tmp" |
| 1264 | rm -f "$tmp" |
| 1265 | [ "$n" -gt 0 ] && addp prozesse info "$n bekannte Angreifer-IP(s) aus AbuseTrack gesperrt (Score ab $score)" "AbuseTrack" |
| 1266 | fi |
| 1267 | fi |
| 1268 | return 0 |
| 1269 | } |
| 1270 | |
| 1271 | # Backup-Assistent: sichert /etc + SSH-Schlüssel + Paketliste täglich auf einen |
| 1272 | # FTP/FTPS-Server (z. B. Nytrix FTP-Storage — nur FTP/FTPS, daher curl statt scp). |
| 1273 | backup_run() { |
| 1274 | local cfg="$STATE/backup"; [ -s "$cfg" ] || return 0 |
| 1275 | have curl || return 0 |
| 1276 | local host port user pass path ftps |
| 1277 | host=$(sed -n 's/^host=//p' "$cfg"); port=$(sed -n 's/^port=//p' "$cfg") |
| 1278 | user=$(sed -n 's/^user=//p' "$cfg"); pass=$(sed -n 's/^pass=//p' "$cfg") |
| 1279 | path=$(sed -n 's/^path=//p' "$cfg"); ftps=$(sed -n 's/^ftps=//p' "$cfg") |
| 1280 | [ -n "$host" ] && [ -n "$user" ] || return 0 |
| 1281 | local today; today=$(date +%Y-%m-%d) |
| 1282 | [ "$(cat "$STATE/backup_last" 2>/dev/null)" = "$today" ] && return 0 |
| 1283 | dpkg --get-selections > /etc/wachhund/installed-packages.txt 2>/dev/null |
| 1284 | local tmp; tmp=$(mktemp /tmp/wh_backup.XXXXXX.tar.gz) |
| 1285 | tar czf "$tmp" --warning=no-file-changed -C / etc root/.ssh 2>/dev/null |
| 1286 | local fname="wachhund-$(hostname)-$today.tar.gz" |
| 1287 | local sslopt=""; [ "$ftps" = "1" ] && sslopt="--ssl-reqd" |
| 1288 | if curl -sS $sslopt --ftp-create-dirs --max-time 600 --user "$user:$pass" \ |
| 1289 | -T "$tmp" "ftp://$host:${port:-21}/${path#/}/$fname" 2>/dev/null; then |
| 1290 | echo "$today" > "$STATE/backup_last" |
| 1291 | addp gesundheit info "Backup erstellt und aufs FTP-Storage geladen ($(du -h "$tmp" 2>/dev/null | cut -f1))" "Backup" |
| 1292 | else |
| 1293 | addp gesundheit warn "Backup-Upload fehlgeschlagen — bitte FTP-Zugang prüfen" "Backup" |
| 1294 | fi |
| 1295 | rm -f "$tmp" |
| 1296 | } |
| 1297 | |
| 1298 | # --- Übertragung ------------------------------------------------------------- |
| 1299 | |
| 1300 | send() { |
| 1301 | have curl || die "curl wird benötigt." |
| 1302 | have python3 || die "python3 wird benötigt." |
| 1303 | [ -n "${API_TOKEN:-}" ] || die "Kein Zugriffs-Token. Zuerst 'wachhund enroll <token>' ausführen." |
| 1304 | CPU=${CPU:-0}; HARDENING=${HARDENING:-100} |
| 1305 | VER=$(cat "$STATE/version" 2>/dev/null || echo "$VERSION") |
| 1306 | WH_URL="$API_URL/checkin.php" WH_TOKEN="$API_TOKEN" \ |
| 1307 | WH_CPU="$CPU" WH_MEMU="$MEM_USED" WH_MEMT="$MEM_TOTAL" WH_DU="$DISK_USED" WH_DT="$DISK_TOTAL" \ |
| 1308 | WH_HARD="$HARDENING" WH_HRT="${HRT:-}" WH_SCHEMA="$SCORE_SCHEMA" \ |
| 1309 | 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' |
| 1310 | import os, json, urllib.request, urllib.error, hashlib, base64, subprocess |
| 1311 | # Ende-zu-Ende: liegt der öffentliche Nutzer-Schlüssel vor, werden Befund-Details vor |
| 1312 | # dem Senden clientseitig verschlüsselt (RSA-OAEP/SHA-256). Die Zentrale sieht dann nur |
| 1313 | # Chiffretext; entschlüsselt wird erst im Browser des Nutzers. Ohne Schlüssel unverändert. |
| 1314 | E2E_PUB = os.path.join(os.environ.get('WH_ST',''), 'e2e_pub.pem') |
| 1315 | _e2e_on = bool(os.environ.get('WH_ST')) and os.path.exists(E2E_PUB) |
| 1316 | def seal(detail): |
| 1317 | if not detail or not _e2e_on: |
| 1318 | return detail |
| 1319 | b = detail.encode('utf-8') |
| 1320 | if len(b) > 300: # RSA-3072-OAEP-SHA256 fasst max. 318 Byte |
| 1321 | b = b[:300] |
| 1322 | while b and (b[-1] & 0xC0) == 0x80: # nicht mitten in einem UTF-8-Zeichen abschneiden |
| 1323 | b = b[:-1] |
| 1324 | try: |
| 1325 | p = subprocess.run(['openssl','pkeyutl','-encrypt','-pubin','-inkey',E2E_PUB, |
| 1326 | '-pkeyopt','rsa_padding_mode:oaep','-pkeyopt','rsa_oaep_md:sha256','-pkeyopt','rsa_mgf1_md:sha256'], |
| 1327 | input=b, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) |
| 1328 | if p.returncode == 0 and p.stdout: |
| 1329 | return 'WHE2E:' + base64.b64encode(p.stdout).decode() |
| 1330 | except Exception: |
| 1331 | pass |
| 1332 | return detail |
| 1333 | def rows(path, cols): |
| 1334 | out=[] |
| 1335 | try: |
| 1336 | for line in open(path, encoding='utf-8'): |
| 1337 | line=line.rstrip('\n') |
| 1338 | if not line: continue |
| 1339 | parts=line.split('\t'); parts += ['']*(len(cols)-len(parts)) |
| 1340 | out.append(dict(zip(cols, parts))) |
| 1341 | except (FileNotFoundError, OSError): |
| 1342 | pass |
| 1343 | return out |
| 1344 | find=[] |
| 1345 | for r in rows(os.environ['WH_FIND'], ['category','severity','title','source','detail']): |
| 1346 | r['dedup_hash']=hashlib.sha256((r['category']+r['title']).encode()).hexdigest() |
| 1347 | r['detail']=seal(r.get('detail','')) |
| 1348 | find.append(r) |
| 1349 | net=rows(os.environ['WH_NET'], ['kind','severity','detail']) |
| 1350 | for r in net: r['detail']=seal(r.get('detail','')) |
| 1351 | # Logins bewusst NICHT versiegeln: ihre Dedup (seen_hash) hängt am Detail, und RSA-OAEP |
| 1352 | # ist zufällig — sonst würde jeder Login bei jedem Check-in als neu gezählt. |
| 1353 | logins=rows(os.environ.get('WH_LOGIN',''), ['kind','detail']) |
| 1354 | inventory=rows(os.environ.get('WH_INV',''), ['kind','name','allowed','detail']) |
| 1355 | for r in inventory: r['allowed'] = (r.get('allowed')=='1') |
| 1356 | activity=rows(os.environ.get('WH_ACT',''), ['kind','detail']) |
| 1357 | tstatus={} |
| 1358 | for r in rows(os.environ.get('WH_TOOL',''), ['tkey','installed','status','summary','detail']): |
| 1359 | tstatus[r['tkey']]={'installed':r['installed']=='1','status':r['status'],'summary':r['summary'],'detail':seal(r.get('detail',''))} |
| 1360 | # Beim letzten Lauf ausgeführte Aufträge quittieren (Dashboard hakt sie in der Aufgaben-Glocke ab). |
| 1361 | done_tasks=[] |
| 1362 | try: |
| 1363 | done_tasks=sorted({x for x in open(os.environ.get('WH_DONE',''), encoding='utf-8').read().split() if x.isalnum()}) |
| 1364 | except (FileNotFoundError, OSError): |
| 1365 | pass |
| 1366 | # Fortschritt der Tool-Einrichtung (welches Tool zuletzt dran war, wie viele noch offen) |
| 1367 | tool_progress=None |
| 1368 | try: |
| 1369 | _tp=open(os.environ.get('WH_TP',''), encoding='utf-8').read().strip().split('\t') |
| 1370 | if len(_tp)>=3 and _tp[0]=='setup': |
| 1371 | tool_progress={'current':_tp[1][:40],'remaining':int(_tp[2])} |
| 1372 | except (FileNotFoundError, OSError, ValueError): |
| 1373 | pass |
| 1374 | # Einzelkriterien der Härtung: nur Schlüssel, Gewicht und erfüllt/nicht erfüllt. Die |
| 1375 | # erklärenden Texte stehen im Dashboard, damit sie sich ohne Agent-Update verbessern lassen. |
| 1376 | haert=[] |
| 1377 | for r in rows(os.environ.get('WH_HRT',''), ['key','pts','ok']): |
| 1378 | try: |
| 1379 | haert.append({'key':r['key'][:24],'pts':int(r['pts']),'ok':r['ok']=='1'}) |
| 1380 | except ValueError: |
| 1381 | pass |
| 1382 | body=json.dumps({ |
| 1383 | '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'])}, |
| 1384 | 'hardening_score':int(os.environ['WH_HARD']),'version':os.environ['WH_VER'], |
| 1385 | 'hardening':{'schema':int(os.environ.get('WH_SCHEMA','1')),'items':haert}, |
| 1386 | 'findings':find,'net_events':net,'logins':logins,'inventory':inventory,'activity':activity,'tool_status':tstatus,'done_tasks':done_tasks,'tool_progress':tool_progress, |
| 1387 | }).encode() |
| 1388 | req=urllib.request.Request(os.environ['WH_URL'], data=body, headers={'Content-Type':'application/json','Authorization':'Bearer '+os.environ['WH_TOKEN']}) |
| 1389 | try: |
| 1390 | print(urllib.request.urlopen(req, timeout=25).read().decode()) |
| 1391 | except urllib.error.HTTPError as ex: |
| 1392 | print('{"revoked":true}' if ex.code == 401 else '{"error":"http %d"}' % ex.code) |
| 1393 | except Exception as ex: |
| 1394 | print('{"error":"%s"}' % str(ex).replace('"',"'")) |
| 1395 | PY |
| 1396 | } |
| 1397 | |
| 1398 | # Zugriff im Dashboard widerrufen? Nach mehrfacher Bestätigung entfernt sich der Agent |
| 1399 | # vollständig selbst. Absichtlich defensiv: ein einzelner 401 (z. B. kurzer Server-Aussetzer) |
| 1400 | # löst nichts aus — erst drei aufeinanderfolgende Widerrufe. Jeder erfolgreiche Check-in |
| 1401 | # setzt den Zähler zurück, Netzfehler lassen ihn unverändert. |
| 1402 | check_revoked() { |
| 1403 | case "$1" in |
| 1404 | *'"revoked":true'*) |
| 1405 | local c; c=$(cat "$STATE/revoked_count" 2>/dev/null); case "$c" in ''|*[!0-9]*) c=0 ;; esac |
| 1406 | c=$((c+1)); echo "$c" > "$STATE/revoked_count" |
| 1407 | if [ "$c" -ge 3 ]; then |
| 1408 | echo "Zugriff im Dashboard widerrufen (mehrfach bestätigt) — Wachhund entfernt sich jetzt vollständig." >&2 |
| 1409 | cmd_uninstall |
| 1410 | exit 0 |
| 1411 | fi |
| 1412 | echo "Zugriff scheint widerrufen (Bestätigung $c/3) — Übertragung pausiert bis zur nächsten Prüfung." >&2 |
| 1413 | exit 0 ;; |
| 1414 | *'"ok":true'*) |
| 1415 | rm -f "$STATE/revoked_count" ;; |
| 1416 | esac |
| 1417 | } |
| 1418 | |
| 1419 | # Baseline- und Tool-Schalter aus der Antwort für den nächsten Lauf merken. |
| 1420 | # Antwort als Umgebungsvariable übergeben — stdin ist durch das Heredoc-Programm belegt. |
| 1421 | persist_config() { |
| 1422 | WH_ST="$STATE" WH_RESP="$1" python3 - <<'PY' |
| 1423 | import os, json |
| 1424 | try: d=json.loads(os.environ.get('WH_RESP','') or '{}') |
| 1425 | except Exception: raise SystemExit(0) |
| 1426 | st=os.environ['WH_ST'] |
| 1427 | def w(name, arr): |
| 1428 | arr=[x for x in arr if isinstance(x, str) and x.replace('_','').isalnum()] |
| 1429 | open(os.path.join(st, name), 'w').write(' '.join(arr)) |
| 1430 | w('baseline', d.get('baseline', []) or []) |
| 1431 | w('tools', d.get('tools', []) or []) |
| 1432 | at=d.get('abusetrack') |
| 1433 | if isinstance(at, dict) and at.get('key'): |
| 1434 | p=os.path.join(st,'at_key'); open(p,'w').write(str(at['key'])); os.chmod(p, 0o600) |
| 1435 | fl=[] |
| 1436 | if at.get('report'): fl.append('report') |
| 1437 | if at.get('block'): fl.append('block') |
| 1438 | open(os.path.join(st,'at_flags'),'w').write(' '.join(fl)) |
| 1439 | try: sc=int(at.get('score',25)) |
| 1440 | except (TypeError, ValueError): sc=25 |
| 1441 | open(os.path.join(st,'at_score'),'w').write(str(min(100, max(25, sc)))) |
| 1442 | else: |
| 1443 | for f in ('at_key','at_flags','at_score'): |
| 1444 | try: os.remove(os.path.join(st,f)) |
| 1445 | except OSError: pass |
| 1446 | up=d.get('update') |
| 1447 | if isinstance(up, dict) and up.get('version'): |
| 1448 | open(os.path.join(st,'pending_update'),'w').write(str(up['version'])) |
| 1449 | open(os.path.join(st,'pending_update_sig'),'w').write(str(up.get('sig') or '')) |
| 1450 | else: |
| 1451 | for f in ('pending_update','pending_update_sig'): |
| 1452 | try: os.remove(os.path.join(st,f)) |
| 1453 | except OSError: pass |
| 1454 | ts=d.get('tool_settings') |
| 1455 | if isinstance(ts, dict): |
| 1456 | for tk,sv in ts.items(): |
| 1457 | if isinstance(tk,str) and tk.isalnum() and isinstance(sv,dict): |
| 1458 | out='' |
| 1459 | for k,v in sv.items(): |
| 1460 | if isinstance(k,str) and k.isalnum() and isinstance(v,(int,float)): |
| 1461 | out+='%s=%d\n'%(k,int(v)) |
| 1462 | open(os.path.join(st,'toolset_'+tk),'w').write(out) |
| 1463 | tk=d.get('tasks') |
| 1464 | if isinstance(tk, list): |
| 1465 | open(os.path.join(st,'tasks'),'w').write(' '.join(x for x in tk if isinstance(x,str) and x.isalnum())) |
| 1466 | bk=d.get('backup') |
| 1467 | if isinstance(bk, dict) and bk.get('host') and bk.get('user'): |
| 1468 | lines=''.join('%s=%s\n'%(k, str(bk.get(k,'')).replace('\n','').replace('\r','')) for k in ('host','port','user','pass','path','ftps')) |
| 1469 | p=os.path.join(st,'backup'); open(p,'w').write(lines); os.chmod(p, 0o600) |
| 1470 | else: |
| 1471 | try: os.remove(os.path.join(st,'backup')) |
| 1472 | except OSError: pass |
| 1473 | # Öffentlicher E2E-Schlüssel des Nutzers (nicht geheim): merken, damit send() Details versiegelt. |
| 1474 | pub=d.get('e2e_pubkey') |
| 1475 | if isinstance(pub, str) and 'PUBLIC KEY' in pub and len(pub) < 4000: |
| 1476 | open(os.path.join(st,'e2e_pub.pem'),'w').write(pub) |
| 1477 | else: |
| 1478 | try: os.remove(os.path.join(st,'e2e_pub.pem')) |
| 1479 | except OSError: pass |
| 1480 | PY |
| 1481 | } |
| 1482 | |
| 1483 | # System-Updates über die gängigen Paketverwaltungen. Debian/Ubuntu/Proxmox nutzen apt; |
| 1484 | # zusätzlich dnf/yum (RHEL/Fedora/Rocky/Alma), zypper (openSUSE) und pacman (Arch). |
| 1485 | os_update() { |
| 1486 | local n=0 |
| 1487 | if have apt-get; then |
| 1488 | apt-get update -qq >/dev/null 2>&1 |
| 1489 | n=$(DEBIAN_FRONTEND=noninteractive apt-get -y -qq -o Dpkg::Options::=--force-confold upgrade 2>&1 | grep -ciE '^(Setting up|Unpacking) ') |
| 1490 | elif have dnf; then |
| 1491 | n=$(dnf -y --refresh upgrade 2>&1 | grep -ciE '^[[:space:]]*(Upgrading|Installing|Updating)[[:space:]]') |
| 1492 | elif have yum; then |
| 1493 | n=$(yum -y update 2>&1 | grep -ciE '^[[:space:]]*(Updating|Installing)[[:space:]]') |
| 1494 | elif have zypper; then |
| 1495 | zypper --non-interactive refresh >/dev/null 2>&1 |
| 1496 | n=$(zypper --non-interactive update -y 2>&1 | grep -ciE '^(Installing|Updating):') |
| 1497 | elif have pacman; then |
| 1498 | n=$(pacman -Syu --noconfirm 2>&1 | grep -ciE '^(installing|upgrading) ') |
| 1499 | else |
| 1500 | addp updates warn "System-Updates nicht möglich: keine bekannte Paketverwaltung gefunden (apt/dnf/yum/zypper/pacman)" "Update"; return 0 |
| 1501 | fi |
| 1502 | case "$n" in ''|*[!0-9]*) n=0 ;; esac |
| 1503 | addp updates info "${n} System-Update(s) über das Dashboard eingespielt" "Update" |
| 1504 | } |
| 1505 | |
| 1506 | # Freigegebene Aufträge aus dem Dashboard ausführen (feste Liste, kein Code vom Panel). |
| 1507 | # Jede bearbeitete Aufgabe wird beim nächsten Kontakt quittiert (Aufgaben-Glocke im Dashboard). |
| 1508 | run_tasks() { |
| 1509 | local tasks t; tasks=$(cat "$STATE/tasks" 2>/dev/null); [ -n "$tasks" ] || return 0 |
| 1510 | : > "$STATE/tasks" |
| 1511 | for t in $tasks; do |
| 1512 | case "$t" in |
| 1513 | rebaseline) |
| 1514 | rm -f "$STATE/.initialized" "$STATE/.act_init" "$STATE/allowed_ports" "$STATE/allowed_svcs" |
| 1515 | # Auch die Prüfsummen-Datenbanken der Integritäts-Tools neu einlernen, damit gewollte |
| 1516 | # Änderungen (z. B. nach einem Update) nicht weiter als Abweichung gemeldet werden. |
| 1517 | have rkhunter && rkhunter --propupd --quiet >/dev/null 2>&1 |
| 1518 | rm -f /var/lib/aide/aide.db 2>/dev/null # AIDE legt seine Datenbank beim nächsten Kontakt neu an |
| 1519 | addp konten info "Normalzustand neu eingelernt — Konten, Ports, Dienste und die Prüfsummen-Datenbanken (AIDE/rkhunter)" "Baseline" ;; |
| 1520 | osupdate) |
| 1521 | os_update ;; |
| 1522 | scan) |
| 1523 | # Gründlicher Rundgang auf Anforderung. WICHTIG: Tiefenscans aller Tools (v. a. der |
| 1524 | # ClamAV-Vollscan) können mit vielen aktivierten Werkzeugen etliche Minuten dauern — |
| 1525 | # das darf den Minuten-Herzschlag NICHT blockieren, sonst greift dessen Zeitlimit und |
| 1526 | # die Aufgabe bleibt hängen. Deshalb den separaten Scan-Dienst im Hintergrund anstoßen. |
| 1527 | rm -f "$STATE"/tool_*_ts 2>/dev/null # Throttle aufheben, damit die Scans jetzt laufen |
| 1528 | if have systemctl && systemctl start --no-block wachhund-scan.service 2>/dev/null; then |
| 1529 | addp gesundheit info "Gründlicher Rundgang gestartet — die Ergebnisse folgen in Kürze" "Rundgang" |
| 1530 | else |
| 1531 | ( WH_DEEP=1 manage_tools ) # Fallback ohne systemd: bestmöglich direkt |
| 1532 | addp gesundheit info "Gründlicher Rundgang durchgeführt" "Rundgang" |
| 1533 | fi ;; |
| 1534 | esac |
| 1535 | printf '%s\n' "$t" >> "$STATE/done_tasks" |
| 1536 | done |
| 1537 | } |
| 1538 | |
| 1539 | # Prüft die ed25519-Signatur eines heruntergeladenen Agenten gegen AGENT_PUBKEY. |
| 1540 | # Rückgabe 0 = einspielen erlaubt (gültige Signatur, oder mangels geliefertem sig bzw. |
| 1541 | # fehlender openssl-Rohprüfung nicht strenger als das bisherige TLS-Vertrauen). |
| 1542 | # Rückgabe 1 = eine Signatur liegt vor, ist aber ungültig -> Update ablehnen. |
| 1543 | verify_update_sig() { |
| 1544 | local f="$1" sig; sig=$(cat "$STATE/pending_update_sig" 2>/dev/null) |
| 1545 | if [ -z "$sig" ]; then |
| 1546 | addp updates info "Agent-Update ohne Signatur eingespielt (nur über TLS geprüft)" "Update"; return 0 |
| 1547 | fi |
| 1548 | if ! have openssl || ! openssl pkeyutl -help 2>&1 | grep -q -- '-rawin'; then |
| 1549 | addp updates info "Agent-Update-Signatur nicht geprüft (openssl ohne ed25519-Rohprüfung) — nur TLS" "Update"; return 0 |
| 1550 | fi |
| 1551 | local pub sigbin rc |
| 1552 | pub=$(mktemp); printf '%s\n' "$AGENT_PUBKEY" > "$pub" |
| 1553 | sigbin=$(mktemp); printf '%s' "$sig" | base64 -d > "$sigbin" 2>/dev/null |
| 1554 | if openssl pkeyutl -verify -pubin -inkey "$pub" -rawin -in "$f" -sigfile "$sigbin" >/dev/null 2>&1; then rc=0; else rc=1; fi |
| 1555 | rm -f "$pub" "$sigbin" |
| 1556 | return "$rc" |
| 1557 | } |
| 1558 | |
| 1559 | # Sicheres Selbst-Update: nur wenn im Dashboard freigegeben (pending_update gesetzt). |
| 1560 | # Lädt über TLS von wachhund.io, prüft gültiges Bash + erwartete Version + Signatur, |
| 1561 | # ersetzt erst danach atomar. |
| 1562 | apply_update() { |
| 1563 | local newver curver; newver=$(cat "$STATE/pending_update" 2>/dev/null); [ -n "$newver" ] || return 0 |
| 1564 | curver=$(cat "$STATE/version" 2>/dev/null || echo "$VERSION") |
| 1565 | [ "$newver" = "$curver" ] && { rm -f "$STATE/pending_update" "$STATE/pending_update_sig"; return 0; } |
| 1566 | have curl || return 0 |
| 1567 | local tmp="/usr/local/bin/.wachhund.new" |
| 1568 | if curl -fsSL --max-time 30 "https://wachhund.io/wachhund" -o "$tmp" 2>/dev/null; then |
| 1569 | local dlver; dlver=$(grep -m1 '^VERSION=' "$tmp" | cut -d= -f2) |
| 1570 | if head -1 "$tmp" | grep -q 'bash' && [ "$dlver" = "$newver" ] && bash -n "$tmp" 2>/dev/null && verify_update_sig "$tmp"; then |
| 1571 | chmod 755 "$tmp"; mv -f "$tmp" /usr/local/bin/wachhund |
| 1572 | echo "$newver" > "$STATE/version"; rm -f "$STATE/pending_update" "$STATE/pending_update_sig" |
| 1573 | addp updates info "Wachhund-Agent auf Version $newver aktualisiert" "Update" |
| 1574 | printf 'update\n' >> "$STATE/done_tasks" |
| 1575 | else |
| 1576 | rm -f "$tmp"; addp updates warn "Agent-Update abgelehnt (Prüfung oder Signatur fehlgeschlagen)" "Update" |
| 1577 | fi |
| 1578 | else rm -f "$tmp"; fi |
| 1579 | } |
| 1580 | |
| 1581 | # --- Unterbefehle ------------------------------------------------------------ |
| 1582 | |
| 1583 | cmd_enroll() { |
| 1584 | local token="$1" |
| 1585 | [ -n "$token" ] || die "Einrichtungs-Token fehlt." |
| 1586 | have curl || die "curl wird benötigt." |
| 1587 | local host os ip |
| 1588 | host=$(hostname); os=$(. /etc/os-release 2>/dev/null; echo "${PRETTY_NAME:-Linux}") |
| 1589 | ip=$(curl -fsS --max-time 8 https://api.ipify.org 2>/dev/null || echo "") |
| 1590 | local resp; resp=$(curl -fsS --max-time 20 -X POST "$API_URL/enroll.php" \ |
| 1591 | -H 'Content-Type: application/json' \ |
| 1592 | -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")" ) |
| 1593 | local api; api=$(echo "$resp" | python3 -c "import json,sys;print(json.load(sys.stdin).get('api_token',''))" 2>/dev/null) |
| 1594 | [ -n "$api" ] || die "Anmeldung fehlgeschlagen: $resp" |
| 1595 | mkdir -p /etc/wachhund |
| 1596 | { echo "API_URL=$API_URL"; echo "API_TOKEN=$api"; } > "$CONF" |
| 1597 | chmod 600 "$CONF" |
| 1598 | echo "$VERSION" > "$STATE/version" |
| 1599 | API_TOKEN="$api" |
| 1600 | echo "Erfolgreich angemeldet. Der Server erscheint jetzt im Dashboard." |
| 1601 | cmd_checkin |
| 1602 | } |
| 1603 | |
| 1604 | run_collect() { |
| 1605 | [ -n "${FIND:-}" ] && rm -f "$FIND" "$NET" "$LOGIN" "$TOOLSTAT" "${INV:-}" "${ACT:-}" "${HRT:-}" 2>/dev/null # Reste eines vorherigen Schnell-Takt-Laufs |
| 1606 | FIND=$(mktemp); NET=$(mktemp); LOGIN=$(mktemp); TOOLSTAT=$(mktemp); INV=$(mktemp); ACT=$(mktemp); HRT=$(mktemp) |
| 1607 | trap 'rm -f "$FIND" "$NET" "$LOGIN" "$TOOLSTAT" "$INV" "$ACT" "$HRT"' EXIT |
| 1608 | # Gepufferte Meldungen aus dem letzten Lauf (nach dem Senden entstanden) mitschicken |
| 1609 | [ -f "$STATE/pending" ] && { cat "$STATE/pending" >> "$FIND"; rm -f "$STATE/pending"; } |
| 1610 | metrics |
| 1611 | collect_logins |
| 1612 | checks |
| 1613 | inventory |
| 1614 | activity_scan |
| 1615 | } |
| 1616 | run_enforce() { apply_baseline; manage_tools; } |
| 1617 | |
| 1618 | # Selbstheilung des Herzschlag-Timers: stellt sicher, dass Service + Timer existieren, ein |
| 1619 | # Zeitlimit haben (ein hängender Kontakt — z. B. apt-Lock oder langer Scan — darf den Minuten- |
| 1620 | # Takt nicht dauerhaft blockieren) und laufen. Idempotent und billig: nur bei echter Abweichung |
| 1621 | # wird geschrieben und neu geladen. Behebt „meldet sich nicht von selbst"-Aussetzer. |
| 1622 | ensure_timers() { |
| 1623 | [ "$(id -u)" = 0 ] || return 0 |
| 1624 | have systemctl || return 0 |
| 1625 | local changed=0 svc=/etc/systemd/system/wachhund-checkin.service tmr=/etc/systemd/system/wachhund-checkin.timer |
| 1626 | local want_svc='[Unit] |
| 1627 | Description=Wachhund Herzschlag und Check-in |
| 1628 | After=network-online.target |
| 1629 | Wants=network-online.target |
| 1630 | [Service] |
| 1631 | Type=oneshot |
| 1632 | TimeoutStartSec=150 |
| 1633 | ExecStart=/usr/local/bin/wachhund checkin' |
| 1634 | local want_tmr='[Unit] |
| 1635 | Description=Wachhund Herzschlag (minütlich) |
| 1636 | [Timer] |
| 1637 | OnActiveSec=20 |
| 1638 | OnUnitActiveSec=60 |
| 1639 | Persistent=true |
| 1640 | [Install] |
| 1641 | WantedBy=timers.target' |
| 1642 | [ "$(cat "$svc" 2>/dev/null)" = "$want_svc" ] || { printf '%s\n' "$want_svc" > "$svc"; changed=1; } |
| 1643 | [ "$(cat "$tmr" 2>/dev/null)" = "$want_tmr" ] || { printf '%s\n' "$want_tmr" > "$tmr"; changed=1; } |
| 1644 | # Tiefenscan-Dienst: läuft im Hintergrund (auf Anforderung + nachts) und darf lange dauern — |
| 1645 | # großzügiges Zeitlimit als reines Sicherheitsnetz, falls ein Tool wirklich hängt. |
| 1646 | local scv=/etc/systemd/system/wachhund-scan.service |
| 1647 | local want_scv='[Unit] |
| 1648 | Description=Wachhund Tiefenscan |
| 1649 | After=network-online.target |
| 1650 | Wants=network-online.target |
| 1651 | [Service] |
| 1652 | Type=oneshot |
| 1653 | TimeoutStartSec=1800 |
| 1654 | ExecStart=/usr/local/bin/wachhund scan' |
| 1655 | [ "$(cat "$scv" 2>/dev/null)" = "$want_scv" ] || { printf '%s\n' "$want_scv" > "$scv"; changed=1; } |
| 1656 | [ "$changed" = 1 ] && systemctl daemon-reload 2>/dev/null |
| 1657 | systemctl is-active wachhund-checkin.timer >/dev/null 2>&1 || systemctl enable --now wachhund-checkin.timer >/dev/null 2>&1 |
| 1658 | return 0 |
| 1659 | } |
| 1660 | |
| 1661 | cmd_checkin() { |
| 1662 | ensure_timers |
| 1663 | local burst=0 r more |
| 1664 | while :; do |
| 1665 | run_collect; r=$(send); check_revoked "$r" |
| 1666 | case "$r" in *'"ok":true'*) rm -f "$STATE/done_tasks" ;; esac |
| 1667 | persist_config "$r"; run_tasks |
| 1668 | # Härtung + Tool-Einrichtung (kann apt-Installationen anstoßen) nur EINMAL je Aufruf — |
| 1669 | # nicht in jeder Schnell-Takt-Runde, sonst blockiert eine Installation den Prozess zu lange. |
| 1670 | [ "$burst" = 0 ] && { run_enforce; apply_update; } |
| 1671 | # Schnell-Takt: nur für die zügige Quittierung ausgeführter/eingereihter Aufgaben bzw. |
| 1672 | # ein anstehendes Update — in ~8 s erneut melden. Rein ausgehend, wenige Runden. |
| 1673 | # Tool-Installationen laufen bewusst über den normalen Minuten-Takt (ein Tool je Kontakt). |
| 1674 | burst=$((burst+1)); [ "$burst" -ge 4 ] && break |
| 1675 | more=0 |
| 1676 | [ -s "$STATE/done_tasks" ] && more=1 |
| 1677 | [ -s "$STATE/tasks" ] && more=1 |
| 1678 | [ -f "$STATE/pending_update" ] && more=1 |
| 1679 | case "$r" in *'"poll_soon":true'*) more=1 ;; esac |
| 1680 | [ "$more" = 1 ] || break |
| 1681 | sleep 8 |
| 1682 | done |
| 1683 | echo "Übertragen." |
| 1684 | } |
| 1685 | 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."; } |
| 1686 | 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; } |
| 1687 | cmd_uninstall() { |
| 1688 | systemctl disable --now wachhund-checkin.timer wachhund-scan.timer >/dev/null 2>&1 |
| 1689 | rm -f /etc/systemd/system/wachhund-checkin.service /etc/systemd/system/wachhund-checkin.timer \ |
| 1690 | /etc/systemd/system/wachhund-scan.service /etc/systemd/system/wachhund-scan.timer |
| 1691 | systemctl daemon-reload >/dev/null 2>&1 |
| 1692 | # Von Wachhund angelegte Firewall-Reste der AbuseTrack-Sperrliste sauber entfernen, |
| 1693 | # damit keine verwaiste DROP-Regel bzw. kein leeres ipset zurückbleibt. |
| 1694 | if have iptables; then |
| 1695 | iptables -D INPUT -j wh_abuse 2>/dev/null |
| 1696 | iptables -F wh_abuse 2>/dev/null |
| 1697 | iptables -X wh_abuse 2>/dev/null |
| 1698 | fi |
| 1699 | have ipset && ipset destroy wachhund_abuse 2>/dev/null |
| 1700 | # Von Wachhund installierte Sicherheitstools wieder entfernen, damit der Server wie vorher ist — |
| 1701 | # aber nur die, die WIR selbst installiert haben (vorher vorhandene Pakete bleiben unberührt). |
| 1702 | local removed=0 |
| 1703 | if [ -f /var/lib/wachhund/installed_pkgs ] && have apt-get; then |
| 1704 | local wpkgs; wpkgs=$(tr '\n' ' ' < /var/lib/wachhund/installed_pkgs) |
| 1705 | if [ -n "$wpkgs" ]; then |
| 1706 | systemctl stop fail2ban auditd arpwatch psad acct >/dev/null 2>&1 |
| 1707 | DEBIAN_FRONTEND=noninteractive apt-get purge -y -qq $wpkgs >/dev/null 2>&1 |
| 1708 | DEBIAN_FRONTEND=noninteractive apt-get autoremove -y -qq >/dev/null 2>&1 |
| 1709 | removed=1 |
| 1710 | fi |
| 1711 | fi |
| 1712 | rm -rf /var/lib/wachhund /etc/wachhund |
| 1713 | rm -f /usr/local/bin/wachhund |
| 1714 | if [ "$removed" = 1 ]; then |
| 1715 | 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." |
| 1716 | else |
| 1717 | 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." |
| 1718 | fi |
| 1719 | } |
| 1720 | |
| 1721 | case "${1:-}" in |
| 1722 | enroll) cmd_enroll "${2:-}" ;; |
| 1723 | checkin) cmd_checkin ;; |
| 1724 | scan) cmd_scan ;; |
| 1725 | uninstall) cmd_uninstall ;; |
| 1726 | --test) cmd_test ;; |
| 1727 | *) echo "Wachhund-Agent $VERSION. Aufruf: wachhund {enroll <token>|checkin|scan|uninstall|--test}" ;; |
| 1728 | esac |