Files
d4rk_media/tools/waechter.ps1
T
D4rkst3randClaude Opus 5 ab7605a6d2 Der Waechter kennt jetzt die Verwaltungszugaenge von Spieleservern
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>
2026-08-14 23:03:12 +02:00

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" }