# Wacht über die Sicherheitslage des Wirts: offene Türen und wichtige Dateien. # # pwsh -File tools\waechter.ps1 <- prüfen und ggf. melden # pwsh -File tools\waechter.ps1 -Uebernehmen <- den JETZIGEN Stand als richtig eintragen # pwsh -File tools\waechter.ps1 -Still <- prüfen, nur anzeigen, nichts melden # # WOZU. In der Nacht zum 13.08.2026 hat jemand über eine offene Tür in den # Gitea geschrieben (docs/vorfall-2026-08-12-gitea.md). Aufgefallen ist es, # weil danach ein Push nicht mehr ging -- nicht weil etwas es bemerkt hätte. # # DIE STATUSSEITE WAR DIE GANZE ZEIT GRÜN. Sie wäre auch grün geblieben, wenn # der Miner gelaufen wäre. Es fehlte kein Bildschirm, auf dem man es hätte # sehen können; es fehlte etwas, das VON SICH AUS Bescheid sagt. # # Zwei Prüfungen, und beide hätten den Vorfall doppelt erwischt: # # 1. OFFENE TÜREN ZÄHLEN. Port 3000 stand eiskalt elf Tage offen. Diese # Prüfung hätte am ersten Tag gesagt: "da ist ein Port dazugekommen, den # niemand eingetragen hat" -- und sie sagt es auch beim nächsten, den # jemand beim Einrichten aus Bequemlichkeit aufmacht. # # 2. WICHTIGE DATEIEN NACHRECHNEN. Der Angriff war eine Datei, die sich um # 21:13:57 änderte. Eine Prüfsumme über .gitconfig, app.ini, sshd_config # und die Schlüsseldatei hätte das im nächsten Durchlauf gemeldet. # # GEMELDET WIRD NUR BEI ÄNDERUNG. Ein Wächter, der täglich meckert, wird # stummgeschaltet -- und ist dann schlechter als keiner. Dieselbe Lehre wie bei # den 900 Fahrzeugbildern. [CmdletBinding()] param( [string]$Container = 'd4rk-media', [switch]$Uebernehmen, [switch]$Still ) $ErrorActionPreference = 'Stop' function Schritt($t) { Write-Host "==> $t" -ForegroundColor Cyan } function Gut($t) { Write-Host " $t" -ForegroundColor Green } function Warn($t) { Write-Host " $t" -ForegroundColor Yellow } function Schlecht($t) { Write-Host " $t" -ForegroundColor Red } # Ports, die man auf einem Server aus dem Internet NIE erreichbar haben will -- # unabhängig davon, ob sie beim Übernehmen des Standes schon dastanden. Diese # Liste warnt also auch dann, wenn der Zustand "schon immer so" war. Genau das # ist der Punkt: Port 3000 war auch "schon immer so". $HEIKEL = @{ 135 = 'RPC' 139 = 'NetBIOS' 445 = 'SMB-Dateifreigabe' 1433 = 'MS SQL' 3306 = 'MySQL' 3389 = 'RDP' 5432 = 'PostgreSQL' 5985 = 'WinRM (HTTP)' 5986 = 'WinRM (HTTPS)' 6379 = 'Redis' 27017 = 'MongoDB' 47001 = 'WinRM-Listener' } # Was nachgerechnet wird. Änderungen daran sind entweder deine -- dann # übernimmst du sie mit -Uebernehmen -- oder sie sind ein Befund. $DATEIEN = @( 'C:\ProgramData\ssh\sshd_config' 'C:\ProgramData\ssh\administrators_authorized_keys' 'C:\docker\gitea\docker-compose.yml' "$PSScriptRoot\..\docker-compose.yml" ) # Dateien IN Containern, als "container:pfad". $IM_CONTAINER = @( 'gitea:/data/gitea/conf/app.ini' 'gitea:/data/gitea/home/.gitconfig' ) # -- Stand erheben -------------------------------------------------------------- Schritt 'Offene Tueren zaehlen' $offen = @(Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | Where-Object { $_.LocalAddress -in '0.0.0.0', '::' } | Select-Object -ExpandProperty LocalPort -Unique | Sort-Object) Gut "$($offen.Count) Ports lauschen auf allen Adressen" Schritt 'Wichtige Dateien nachrechnen' $hashes = @{} foreach ($d in $DATEIEN) { $p = try { (Resolve-Path $d -ErrorAction Stop).Path } catch { $null } if ($p) { $hashes[$d] = (Get-FileHash $p -Algorithm SHA256).Hash } else { $hashes[$d] = 'FEHLT' } } foreach ($eintrag in $IM_CONTAINER) { $c, $pfad = $eintrag -split ':', 2 # sha256sum IM Container: die Datei liegt in einem Volume, das der Wirt # nicht ohne Weiteres lesen kann -- und ein Umweg ueber docker cp wuerde # bei jedem Lauf eine Kopie anlegen. $h = (docker exec $c sh -c "sha256sum '$pfad' 2>/dev/null | cut -d' ' -f1") -join '' $hashes[$eintrag] = if ($h.Trim()) { $h.Trim().ToUpper() } else { 'FEHLT' } } Gut "$($hashes.Count) Dateien" # -- Mit dem hinterlegten Stand vergleichen ------------------------------------- $basisDatei = Join-Path $PSScriptRoot '..\.waechter-basis.json' $basis = if (Test-Path $basisDatei) { Get-Content $basisDatei -Raw | ConvertFrom-Json } else { $null } $jetzt = [ordered]@{ ports = $offen dateien = $hashes } if ($Uebernehmen -or -not $basis) { $jetzt | ConvertTo-Json -Depth 5 | Set-Content $basisDatei -Encoding UTF8 if (-not $basis) { Schritt 'Kein hinterlegter Stand -- den jetzigen als richtig eingetragen' Warn 'Beim ERSTEN Lauf gibt es nichts zu vergleichen. Ab jetzt wird gemeldet,' Warn 'was sich daran aendert. Sieh die Liste unten einmal durch.' } else { Gut 'Stand uebernommen.' } } $basis = Get-Content $basisDatei -Raw | ConvertFrom-Json $neu = @($offen | Where-Object { $_ -notin $basis.ports }) $weg = @($basis.ports | Where-Object { $_ -notin $offen }) $geaendert = @() foreach ($k in $hashes.Keys) { $alt = $basis.dateien.$k if ($alt -and $alt -ne $hashes[$k]) { $geaendert += $k } } $heikelOffen = @($offen | Where-Object { $HEIKEL.ContainsKey([int]$_) }) # -- Anzeigen ------------------------------------------------------------------- Write-Host '' if ($neu.Count) { Schlecht "NEU offen: $($neu -join ', ')" } if ($weg.Count) { Gut "nicht mehr offen: $($weg -join ', ')" } if ($geaendert.Count) { foreach ($g in $geaendert) { Schlecht "GEAENDERT: $g" } } if (-not $neu.Count -and -not $geaendert.Count) { Gut 'Keine Aenderung gegenueber dem hinterlegten Stand.' } if ($heikelOffen.Count) { Write-Host '' Warn 'Diese lauschen auf allen Adressen und gehoeren nicht ins Internet:' foreach ($p in $heikelOffen) { Warn (" {0,6} {1}" -f $p, $HEIKEL[[int]$p]) } Warn 'OB sie von aussen erreichbar sind, sagt nur eine Messung VON AUSSEN --' Warn 'vom Wirt aus antwortet die eigene Adresse auch dann, wenn die Firewall sperrt.' } # -- Melden --------------------------------------------------------------------- # # Nur bei AENDERUNG, und nur einmal je Zustand. Dafuer wandert eine Kennung des # gemeldeten Zustands in die Einstellungen: bleibt sie gleich, bleibt es still. if ($Still) { Write-Host ''; Gut 'Nur angezeigt (-Still), nichts gemeldet.'; exit 0 } $befund = @() if ($neu.Count) { $befund += "NEU offen: " + ($neu -join ', ') } if ($geaendert.Count) { $befund += "geaendert: " + (($geaendert | ForEach-Object { Split-Path $_ -Leaf }) -join ', ') } $kennung = if ($befund.Count) { ($befund -join ' | ') } else { '' } function LiesEinstellung($schluessel) { $s = @" const db = require('better-sqlite3')('/data/media.db', { readonly: true }); const r = db.prepare('SELECT value FROM settings WHERE key = ?').get('$schluessel'); console.log(r ? r.value : ''); "@ -replace "`r", '' (docker exec $Container node -e $s 2>$null) -join '' } function SchreibEinstellung($schluessel, $wert) { $s = @" let roh = ''; process.stdin.on('data', (d) => (roh += d)).on('end', () => { const db = require('better-sqlite3')('/data/media.db'); db.prepare('INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?) ' + 'ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at') .run('$schluessel', roh.trim(), Date.now()); }); "@ -replace "`r", '' $wert | docker exec -i $Container node -e $s 2>&1 | Out-Null } # Bericht fuer die Statusseite -- immer, auch wenn nichts zu melden ist. $bericht = [ordered]@{ at = [long]([DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()) ok = ($befund.Count -eq 0) ports = $offen.Count heikel = @($heikelOffen) befund = @($befund) } SchreibEinstellung 'waechter.letzte' ($bericht | ConvertTo-Json -Compress) $zuletzt = LiesEinstellung 'waechter.merker' if ($kennung -eq $zuletzt) { Write-Host '' Gut 'Nichts Neues -- dieser Zustand wurde schon gemeldet.' exit 0 } SchreibEinstellung 'waechter.merker' $kennung if (-not $kennung) { Write-Host ''; Gut 'Wieder in Ordnung (Merker geleert).'; exit 0 } $webhook = LiesEinstellung 'discord.webhook' if (-not $webhook) { Write-Host ''; Warn 'Kein Discord-Webhook hinterlegt -- nur angezeigt.'; exit 0 } $felder = @() if ($neu.Count) { $felder += @{ name = 'Neu offen'; value = ($neu -join ', '); inline = $true } } if ($geaendert.Count) { $felder += @{ name = 'Geaenderte Dateien'; value = (($geaendert | ForEach-Object { '`' + (Split-Path $_ -Leaf) + '`' }) -join "`n"); inline = $false } } $rumpf = @{ username = 'd4rk_media' embeds = @(@{ title = 'Die Lage am Server hat sich geaendert' description = "Etwas ist offen oder anders, was vorher nicht so war.`n" + "Wenn du das selbst warst: ``pwsh -File tools\waechter.ps1 -Uebernehmen``" color = 15158332 fields = $felder footer = @{ text = 'D4RKST3R // WAECHTER' } timestamp = (Get-Date).ToUniversalTime().ToString('o') }) } | ConvertTo-Json -Depth 6 $antwort = $rumpf | curl.exe -s -o NUL -w '%{http_code}' -X POST ` -H 'content-type: application/json' --data-binary '@-' $webhook Write-Host '' if ($antwort -in '200', '204') { Gut "Gemeldet (HTTP $antwort)." } else { Schlecht "Melden fehlgeschlagen: HTTP $antwort" }