← Zur Startseite
Volle Transparenz

Der Code, der auf deinem Server läuft.

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.

Version 2.2.24
1,728 Zeilen
90.6 KB
Der Hund ruft an, nie umgekehrt. Der Agent baut ausschließlich ausgehende HTTPS-Verbindungen auf — die Zentrale kann deinen Server niemals ansteuern. Er sendet nur Kennzahlen und Befunde, keine Logs, Dateien oder Passwörter. Updates spielt er nur nach deiner Freigabe ein und prüft dabei eine digitale Signatur (ed25519) — selbst ein gehackter Webserver könnte dir keinen manipulierten Agenten unterschieben.
Datei herunterladen
wachhund · Bash · Version 2.2.24
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.
7set -u
8
9VERSION=2.2.24
10API_URL="${WACHHUND_API:-https://wachhund.io/api}"
11CONF=/etc/wachhund/agent.env
12STATE=/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.
18AGENT_PUBKEY='-----BEGIN PUBLIC KEY-----
19MCowBQYDK2VwAyEAYVaeS1du6zgaPSSoc1VoEqwgWTVCD+q5xxp0/woLu/8=
20-----END PUBLIC KEY-----'
21
22die() { echo "Fehler: $1" >&2; exit 1; }
23have() { command -v "$1" >/dev/null 2>&1; }
24mkdir -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
31metrics() {
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
47add() { 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.
49addp() { printf '%s\t%s\t%s\t%s\t%s\n' "$1" "$2" "$3" "$4" "${5:-}" >> "$STATE/pending"; }
50net() { printf '%s\t%s\t%s\n' "$1" "$2" "$3" >> "$NET"; }
51logn() { printf '%s\t%s\n' "$1" "$(echo "$2" | tr -s ' ' | sed 's/[[:space:]]*$//')" >> "$LOGIN"; }
52tstat(){ printf '%s\t%s\t%s\t%s\t%s\n' "$1" "$2" "$3" "$4" "$(printf '%s' "${5:-}" | tr '\n\t' ' ')" >> "$STATE/toolstat"; }
53
54baseline_get() { cat "$STATE/$1" 2>/dev/null; }
55baseline_set() { printf '%s\n' "$2" > "$STATE/$1"; }
56
57# --- Login-Verlauf -----------------------------------------------------------
58
59collect_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.
77certs() {
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.
129disks() {
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.
167services() {
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.
187persistenz() {
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.
238kapazitaet() {
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.
304suidwache() {
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.
329spurenwache() {
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.
351kernmodule() {
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.
370lebensende() {
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.
412dauerlast() {
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.
433webhaertung() {
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.
476ausgehend() {
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.
561zeitpruefung() {
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.
586db_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
616checks() {
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.
757SCORE_SCHEMA=2
758haertungsgrad() {
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.
820inventory() {
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.
852activity_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
882ssh_reload() { systemctl reload ssh 2>/dev/null || systemctl reload sshd 2>/dev/null || service ssh reload 2>/dev/null || true; }
883ssh_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
885baseline_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
904baseline_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
915baseline_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
924baseline_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
932apply_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
947apt_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}
954throttled() { [ "${WH_DEEP:-0}" = 1 ] || return 1; local last; last=$(cat "$STATE/tool_$1_ts" 2>/dev/null || echo 0); [ $(( $(date +%s) - last )) -ge 82800 ]; }
955mark_run() { date +%s > "$STATE/tool_$1_ts"; }
956
957tool_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
978tool_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
993tool_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
1009tool_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
1022tool_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
1036tool_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
1050tool_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
1068tool_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
1081tool_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
1089tool_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
1104tool_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
1115tool_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
1123tool_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
1136tool_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
1143tool_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
1153manage_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).
1189update_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.
1217abusetrack_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).
1273backup_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
1300send() {
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'
1310import 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.
1314E2E_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)
1316def 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
1333def 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
1344find=[]
1345for 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)
1349net=rows(os.environ['WH_NET'], ['kind','severity','detail'])
1350for 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.
1353logins=rows(os.environ.get('WH_LOGIN',''), ['kind','detail'])
1354inventory=rows(os.environ.get('WH_INV',''), ['kind','name','allowed','detail'])
1355for r in inventory: r['allowed'] = (r.get('allowed')=='1')
1356activity=rows(os.environ.get('WH_ACT',''), ['kind','detail'])
1357tstatus={}
1358for 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).
1361done_tasks=[]
1362try:
1363 done_tasks=sorted({x for x in open(os.environ.get('WH_DONE',''), encoding='utf-8').read().split() if x.isalnum()})
1364except (FileNotFoundError, OSError):
1365 pass
1366# Fortschritt der Tool-Einrichtung (welches Tool zuletzt dran war, wie viele noch offen)
1367tool_progress=None
1368try:
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])}
1372except (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.
1376haert=[]
1377for 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
1382body=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()
1388req=urllib.request.Request(os.environ['WH_URL'], data=body, headers={'Content-Type':'application/json','Authorization':'Bearer '+os.environ['WH_TOKEN']})
1389try:
1390 print(urllib.request.urlopen(req, timeout=25).read().decode())
1391except urllib.error.HTTPError as ex:
1392 print('{"revoked":true}' if ex.code == 401 else '{"error":"http %d"}' % ex.code)
1393except Exception as ex:
1394 print('{"error":"%s"}' % str(ex).replace('"',"'"))
1395PY
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.
1402check_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.
1421persist_config() {
1422 WH_ST="$STATE" WH_RESP="$1" python3 - <<'PY'
1423import os, json
1424try: d=json.loads(os.environ.get('WH_RESP','') or '{}')
1425except Exception: raise SystemExit(0)
1426st=os.environ['WH_ST']
1427def 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))
1430w('baseline', d.get('baseline', []) or [])
1431w('tools', d.get('tools', []) or [])
1432at=d.get('abusetrack')
1433if 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))))
1442else:
1443 for f in ('at_key','at_flags','at_score'):
1444 try: os.remove(os.path.join(st,f))
1445 except OSError: pass
1446up=d.get('update')
1447if 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 ''))
1450else:
1451 for f in ('pending_update','pending_update_sig'):
1452 try: os.remove(os.path.join(st,f))
1453 except OSError: pass
1454ts=d.get('tool_settings')
1455if 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)
1463tk=d.get('tasks')
1464if 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()))
1466bk=d.get('backup')
1467if 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)
1470else:
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.
1474pub=d.get('e2e_pubkey')
1475if isinstance(pub, str) and 'PUBLIC KEY' in pub and len(pub) < 4000:
1476 open(os.path.join(st,'e2e_pub.pem'),'w').write(pub)
1477else:
1478 try: os.remove(os.path.join(st,'e2e_pub.pem'))
1479 except OSError: pass
1480PY
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).
1485os_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).
1508run_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.
1543verify_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.
1562apply_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
1583cmd_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
1604run_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}
1616run_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.
1622ensure_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]
1627Description=Wachhund Herzschlag und Check-in
1628After=network-online.target
1629Wants=network-online.target
1630[Service]
1631Type=oneshot
1632TimeoutStartSec=150
1633ExecStart=/usr/local/bin/wachhund checkin'
1634 local want_tmr='[Unit]
1635Description=Wachhund Herzschlag (minütlich)
1636[Timer]
1637OnActiveSec=20
1638OnUnitActiveSec=60
1639Persistent=true
1640[Install]
1641WantedBy=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]
1648Description=Wachhund Tiefenscan
1649After=network-online.target
1650Wants=network-online.target
1651[Service]
1652Type=oneshot
1653TimeoutStartSec=1800
1654ExecStart=/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
1661cmd_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}
1685cmd_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."; }
1686cmd_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; }
1687cmd_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
1721case "${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}" ;;
1728esac
© Enjyn Gruppe — enjyn.de · Bavarian Innovation, Tailored for You