Der Betreiber hat es angemahnt, waehrend nebenan am Spiele-Panel gearbeitet
wurde: "vergiss nicht die ports an den waechter weiter zu geben."
Nachgesehen und gemessen -- die offenen Ports des Panels standen bereits in
der Basis (25566, 26014), keine Abweichung. Dabei ist etwas anderes
aufgefallen.
AUF 10011 LAUSCHT ts3server.exe AUF ALLEN ADRESSEN, aus C:\Server\TS3, seit dem
09.07.2026. Und eine handangelegte Regel laesst herein:
Regelname: TS3_10011 (TCP)
Richtung: Eingehend
Profile: Domaene, Privat, Oeffentlich
Remote-IP: Beliebig
Aktion: Zulassen
Das ist kein Spielport. Ueber ServerQuery verwaltet man einen TeamSpeak-Server
komplett -- Rechte, Konten, Kennwoerter. Im Panel nebenan ist derselbe Port
deshalb ausdruecklich an 127.0.0.1 gebunden und bekommt KEINE Firewallregel.
DER WAECHTER SCHWIEG DAZU, weil 10011 beim Uebernehmen des Standes schon
dastand -- also genau der Fall, fuer den die HEIKEL-Liste gedacht ist: "Port
3000 war auch schon immer so."
Dazu drei weitere aus derselben Familie, bevor sie hier auftauchen: Minecraft
RCON (25575), Rust RCON (28016) und die Palworld-REST-API (8212, kann kicken
und bannen). Im Panel stehen alle drei auf `veroeffentlichen: 'lokal'`; wenn
einer von ihnen je auf allen Adressen lauscht, ist etwas an einem Server von
Hand eingerichtet worden.
Gemessen nach der Aenderung (-Still): der Waechter meldet 10011 jetzt neben
RPC, SMB, MySQL, RDP und WinRM. Was er weiterhin NICHT sieht, ist UDP -- und
die meisten Spielports sind UDP. Das steht als naechstes an.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
243 lines
10 KiB
PowerShell
243 lines
10 KiB
PowerShell
# 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'
|
|
# VERWALTUNGSZUGAENGE VON SPIELESERVERN. Sie sehen harmloser aus als sie
|
|
# sind, weil ein Spieleserver ja erreichbar sein SOLL -- aber über diese
|
|
# Ports verwaltet man ihn komplett, nicht spielt man auf ihm.
|
|
#
|
|
# Am 14.08.2026 gemessen: auf dieser Maschine lauschte 10011 auf allen
|
|
# Adressen (ts3server.exe aus C:\Server\TS3, läuft seit dem 09.07.2026),
|
|
# und eine handangelegte Regel "TS3_10011 (TCP)" ließ beliebige
|
|
# Gegenstellen in allen Profilen herein. Der Wächter schwieg, weil der Port
|
|
# beim Übernehmen des Standes schon dastand -- genau der Fall, für den es
|
|
# diese Liste gibt ("Port 3000 war auch schon immer so").
|
|
#
|
|
# Im Panel (d4rk_gameserver) ist derselbe Port deshalb ausdrücklich an
|
|
# 127.0.0.1 gebunden und bekommt KEINE Firewallregel.
|
|
10011 = 'TeamSpeak ServerQuery (Vollzugriff auf den Server)'
|
|
25575 = 'Minecraft RCON (Fernsteuerung der Konsole)'
|
|
28016 = 'Rust RCON (Fernsteuerung der Konsole)'
|
|
8212 = 'Palworld REST-API (Verwaltung, inkl. Kick und Bann)'
|
|
}
|
|
|
|
# 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" }
|