From d3407ff6136d89f85ff3fcb6820efdeda2e90e42 Mon Sep 17 00:00:00 2001 From: nexxo Date: Mon, 3 Aug 2026 13:49:34 +0200 Subject: [PATCH] Melder: gescheiterte Anmeldungen lesen, zaehlen, sperren MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FailedLoginReader liest Nextclouds Protokoll ueber den Gastagenten (mit Byte-Versatz und Rotationserkennung) und die SSH-Anmeldungen eines Hosts ueber journalctl. ScanForIntrusions bringt beides mit BlockAddress zusammen, jede Minute auf der provisioning-Warteschlange, und traegt am Ende jede noch gueltige Sperre mit ihrer RESTLAUFZEIT erneut in die Firewall ein — der Fall, der einen Neustart des Hosts uebersteht. NextcloudOcc bekommt einen zweiten Baustein (exec()) fuer Gastbefehle jenseits von occ, ohne die Ein-Ort-Regel fuer "docker compose exec" zu verletzen. --- app/Provisioning/Jobs/ScanForIntrusions.php | 145 ++++++++++++ app/Services/Proxmox/FakeProxmoxClient.php | 12 +- app/Services/Security/FailedLoginReader.php | 212 ++++++++++++++++++ app/Support/NextcloudOcc.php | 12 + routes/console.php | 8 + .../Security/ScanForIntrusionsTest.php | 152 +++++++++++++ 6 files changed, 539 insertions(+), 2 deletions(-) create mode 100644 app/Provisioning/Jobs/ScanForIntrusions.php create mode 100644 app/Services/Security/FailedLoginReader.php create mode 100644 tests/Feature/Security/ScanForIntrusionsTest.php diff --git a/app/Provisioning/Jobs/ScanForIntrusions.php b/app/Provisioning/Jobs/ScanForIntrusions.php new file mode 100644 index 0000000..cc0f53d --- /dev/null +++ b/app/Provisioning/Jobs/ScanForIntrusions.php @@ -0,0 +1,145 @@ +onQueue('provisioning'); + } + + /** + * Ohne Methodeninjektion: `Schedule::job(new ScanForIntrusions)` (wie jeder + * andere Auftrag in routes/console.php) erzeugt die Instanz direkt statt + * über den Container, und `app(ScanForIntrusions::class)->handle()` ruft + * `handle()` als gewöhnlichen Methodenaufruf auf — beides läuft ohne + * `Container::call()`, das typisierte Parameter sonst auflöst. Also holt + * sich `handle()` seine Abhängigkeiten selbst. + */ + public function handle(): void + { + $reader = app(FailedLoginReader::class); + $blocker = app(BlockAddress::class); + $firewall = app(HostFirewall::class); + + $this->scanInstances($reader, $blocker); + $this->scanHosts($reader, $blocker); + $this->reapplyActiveBlocks($firewall); + } + + private function scanInstances(FailedLoginReader $reader, BlockAddress $blocker): void + { + $instances = Instance::query() + ->where('status', 'active') + ->whereNotNull('vmid') + ->get(); + + foreach ($instances as $instance) { + $result = $reader->fromInstance($instance); + + // Gast antwortet nicht — kein Vorfall, Versatz bleibt unangetastet. + if ($result === null) { + continue; + } + + foreach ($result['addresses'] as $ip => $attempts) { + if ($attempts >= self::THRESHOLD) { + $blocker->forInstance($instance, $ip, $attempts); + } + } + + // Nur bei Erfolg speichern — genau die Bytes, die wirklich + // gelesen wurden. + $instance->forceFill(['security_log_offset' => $result['offset']])->save(); + } + } + + private function scanHosts(FailedLoginReader $reader, BlockAddress $blocker): void + { + $hosts = Host::query() + ->whereNotNull('wg_ip') + ->whereNotNull('ssh_host_key') + ->get(); + + foreach ($hosts as $host) { + $result = $reader->fromHost($host); + + if ($result === null) { + continue; + } + + foreach ($result['addresses'] as $ip => $attempts) { + if ($attempts >= self::THRESHOLD) { + $blocker->forHost($host, $ip, $attempts); + } + } + + $host->forceFill(['security_log_seen_at' => $result['seenAt']])->save(); + } + } + + private function reapplyActiveBlocks(HostFirewall $firewall): void + { + $blocks = SecurityBlock::active()->with(['host', 'instance.host'])->get(); + + foreach ($blocks as $block) { + $host = $block->host ?? $block->instance?->host; + + // Instanz noch nicht platziert, oder ihr Host inzwischen weg — + // nichts, wo einzutragen wäre. + if ($host === null) { + continue; + } + + // Gerundet, nicht abgeschnitten: Carbon 3 liefert diffInSeconds als + // Fliesskommazahl, und zwischen dem Anlegen der Sperre und diesem + // Lauf vergehen immer ein paar Millisekunden — ein bloßes (int) + // wuerde JEDE Restlaufzeit um bis zu eine Sekunde verkuerzen. + $seconds = (int) round(now()->diffInSeconds($block->expires_at)); + + if ($seconds > 0) { + $firewall->block($host, $block->ip, $seconds); + } + } + } +} diff --git a/app/Services/Proxmox/FakeProxmoxClient.php b/app/Services/Proxmox/FakeProxmoxClient.php index d3c2e04..c485cc5 100644 --- a/app/Services/Proxmox/FakeProxmoxClient.php +++ b/app/Services/Proxmox/FakeProxmoxClient.php @@ -71,8 +71,16 @@ class FakeProxmoxClient implements ProxmoxClient /** @var array recorded guest commands */ public array $guestCommands = []; - /** @var array */ - private array $guestScripts = []; + /** + * Public rather than private: ScanForIntrusionsTest scripts entries by + * direct array assignment (`$pve->guestScripts['nextcloud.log'] = […]`) + * instead of guestScript(), because the fixture needs to omit `exitcode` + * or `out-data` outright to simulate a guest that gives back nothing + * usable — guestScript()'s signature always supplies both. + * + * @var array + */ + public array $guestScripts = []; public int $guestDefaultExit = 0; diff --git a/app/Services/Security/FailedLoginReader.php b/app/Services/Security/FailedLoginReader.php new file mode 100644 index 0000000..c237994 --- /dev/null +++ b/app/Services/Security/FailedLoginReader.php @@ -0,0 +1,212 @@ +}|null + */ + public function fromInstance(Instance $instance): ?array + { + $host = $instance->host; + + // Kein Host, keine Maschine — nichts, was der Gastagent fragen könnte. + if ($host === null || $instance->vmid === null) { + return null; + } + + $pve = $this->pve->forHost($host); + $node = (string) ($host->node ?? 'pve'); + $vmid = (int) $instance->vmid; + $offset = (int) $instance->security_log_offset; + + $size = $pve->guestExec($node, $vmid, NextcloudOcc::exec('stat -c %s data/nextcloud.log')); + + if ((int) ($size['exitcode'] ?? 1) !== 0) { + return null; + } + + // Kleiner als der gemerkte Versatz heißt: rotiert. Ohne diese + // Behandlung liest der nächste Aufruf ins Leere und sieht nie wieder + // etwas — siehe den eigenen Testfall dafür. + if ((int) trim((string) ($size['out-data'] ?? '')) < $offset) { + $offset = 0; + } + + $tail = $pve->guestExec($node, $vmid, NextcloudOcc::exec('tail -c +'.($offset + 1).' data/nextcloud.log')); + + if ((int) ($tail['exitcode'] ?? 1) !== 0) { + return null; + } + + $out = (string) ($tail['out-data'] ?? ''); + + return [ + 'offset' => $offset + strlen($out), + 'addresses' => $this->countWithinWindow($this->parseNextcloudLines($out)), + ]; + } + + /** + * Die fehlgeschlagenen SSH-Anmeldungen seit dem gemerkten Zeitpunkt. + * + * Verbindet direkt über die WireGuard-Adresse, nicht über + * `HostStep::keyLogin()`s Rückfallpfad auf die öffentliche Adresse — wie + * `HostFirewall`: dieser Melder läuft gegen Hosts, die den Tunnel schon + * haben, nie während der Inbetriebnahme selbst. + * + * @return array{seenAt: Carbon, addresses: array}|null + */ + public function fromHost(Host $host): ?array + { + // Ohne einen gemerkten Zeitpunkt nur das Fenster selbst lesen, statt + // journalctl die ganze Historie des Hosts durchsuchen zu lassen. + $since = ($host->security_log_seen_at ?? now()->subMinutes(self::WINDOW_MINUTES)) + ->format('Y-m-d H:i:s'); + + try { + $this->shell->connectWithKey( + $host->wg_ip, + 'root', + (string) app(SecretVault::class)->get('ssh.private_key'), + $host->ssh_host_key, // gepinnt bei EstablishSshTrust + ); + + $result = $this->shell->run( + 'journalctl -u ssh -u sshd --since '.escapeshellarg($since).' -o cat' + ); + } catch (Throwable) { + // Nicht erreichbar — kein Vorfall, kein neuer Zeitpunkt. + return null; + } + + if (! $result->ok()) { + return null; + } + + return [ + 'seenAt' => now(), + 'addresses' => $this->countSshFailures($result->stdout), + ]; + } + + /** + * Nur Zeilen, deren `message` mit "Login failed:" beginnt — Nextclouds + * eigenes Format, stabil über Versionen hinweg (anders als + * `occ security:bruteforce:*`, dessen Unterbefehle gewechselt haben). + * + * @return array + */ + private function parseNextcloudLines(string $out): array + { + $entries = []; + + foreach (preg_split('/\R/', trim($out)) ?: [] as $line) { + if ($line === '') { + continue; + } + + $decoded = json_decode($line, true); + + if (! is_array($decoded) + || ! str_starts_with((string) ($decoded['message'] ?? ''), 'Login failed:')) { + continue; + } + + $ip = (string) ($decoded['remoteAddr'] ?? ''); + + if ($ip === '' || ! isset($decoded['time'])) { + continue; + } + + $entries[] = ['ip' => $ip, 'time' => Carbon::parse($decoded['time'])]; + } + + return $entries; + } + + /** + * Nur, was höchstens WINDOW_MINUTES alt ist, zählt gegen die Schwelle — + * zehn Versuche über zwei Fenster verteilt sind eben keine zehn. + * + * @param array $entries + * @return array + */ + private function countWithinWindow(array $entries): array + { + $cutoff = now()->subMinutes(self::WINDOW_MINUTES); + $counts = []; + + foreach ($entries as $entry) { + if ($entry['time']->lessThan($cutoff)) { + continue; + } + + $counts[$entry['ip']] = ($counts[$entry['ip']] ?? 0) + 1; + } + + return $counts; + } + + /** + * `Failed password` und `Invalid user` — die Adresse steht in derselben + * Zeile. `--since` hat den zeitlichen Ausschnitt schon auf den Gastagenten + * (journalctl) verlagert, hier wird nur noch gezählt. + * + * @return array + */ + private function countSshFailures(string $out): array + { + $counts = []; + + foreach (preg_split('/\R/', trim($out)) ?: [] as $line) { + if ($line === '' + || (! str_contains($line, 'Failed password') && ! str_contains($line, 'Invalid user'))) { + continue; + } + + if (preg_match('/from ([0-9a-fA-F:.]+) port/', $line, $match) !== 1) { + continue; + } + + $counts[$match[1]] = ($counts[$match[1]] ?? 0) + 1; + } + + return $counts; + } +} diff --git a/app/Support/NextcloudOcc.php b/app/Support/NextcloudOcc.php index 0c6baa5..b638e6b 100644 --- a/app/Support/NextcloudOcc.php +++ b/app/Support/NextcloudOcc.php @@ -60,4 +60,16 @@ final class NextcloudOcc return 'cd '.self::DIRECTORY.' && '.$assignments .'docker compose exec -T -u '.self::USER.' '.$forwards.'app php occ '.$arguments; } + + /** + * A guest shell command inside the SAME container, for anything that is not + * `occ` — FailedLoginReader's `stat`/`tail` on Nextcloud's own log file, for + * instance. Same account, same "one place" rule as command() above: nothing + * else in app/ may spell `docker compose exec` out by hand, and the test + * next to that rule only exempts this file. + */ + public static function exec(string $arguments): string + { + return 'cd '.self::DIRECTORY.' && docker compose exec -T -u '.self::USER.' app '.$arguments; + } } diff --git a/routes/console.php b/routes/console.php index dd72eba..277ea06 100644 --- a/routes/console.php +++ b/routes/console.php @@ -6,6 +6,7 @@ use App\Provisioning\Jobs\CollectHostLoad; use App\Provisioning\Jobs\CollectInstanceTraffic; use App\Provisioning\Jobs\PingHosts; use App\Provisioning\Jobs\RecordProvisioningHeartbeat; +use App\Provisioning\Jobs\ScanForIntrusions; use App\Provisioning\Jobs\SyncMonitoringStatus; use App\Provisioning\Jobs\SyncVpnPeers; use App\Support\Settings; @@ -265,3 +266,10 @@ Schedule::command('clupilot:check-certificates') ->dailyAt('04:17') ->withoutOverlapping() ->name('check-certificates'); + +// Jede Minute: ein Angriff, der zehn Minuten läuft, soll nicht zehn Minuten +// unbemerkt laufen. Der Auftrag ist billig, wenn nichts zu tun ist — er liest +// nur den Zuwachs seit dem letzten Mal. +Schedule::job(new ScanForIntrusions) + ->everyMinute() + ->withoutOverlapping(); diff --git a/tests/Feature/Security/ScanForIntrusionsTest.php b/tests/Feature/Security/ScanForIntrusionsTest.php new file mode 100644 index 0000000..863ec87 --- /dev/null +++ b/tests/Feature/Security/ScanForIntrusionsTest.php @@ -0,0 +1,152 @@ +instance(RemoteShell::class, new FakeRemoteShell); +}); + +function protokollZeilen(string $ip, int $anzahl): string +{ + return collect(range(1, $anzahl)) + ->map(fn () => json_encode([ + 'app' => 'core', + 'message' => "Login failed: 'admin' (Remote IP: '{$ip}')", + 'remoteAddr' => $ip, + 'time' => now()->toIso8601String(), + ])) + ->implode("\n"); +} + +/** + * Eine aktive Instanz mit vmid braucht in Wahrheit immer einen Host — ohne + * Platzierung gäbe es keinen Gastagenten zu fragen (siehe + * IssueInstanceAdminAccess, das genau deshalb auf `$instance->host === null` + * prüft). Der Auftragszettel ließ host_id in seinen Fixturen weg; das hätte + * FailedLoginReader::fromInstance() nie erreicht, weil sie ohne Host abbricht. + */ +function aktiveInstanz(array $attributes = []): Instance +{ + return Instance::factory()->create(array_merge([ + 'status' => 'active', + 'vmid' => 101, + 'host_id' => Host::factory()->active()->create()->id, + ], $attributes)); +} + +it('sperrt ab zehn Fehlversuchen im Fenster', function () { + $pve = new FakeProxmoxClient; + $pve->guestScripts['nextcloud.log'] = ['out-data' => protokollZeilen('203.0.113.7', 10), 'exitcode' => 0]; + app()->instance(\App\Services\Proxmox\ProxmoxClient::class, $pve); + + aktiveInstanz(); + app(ScanForIntrusions::class)->handle(); + + expect(SecurityBlock::where('ip', '203.0.113.7')->exists())->toBeTrue(); +}); + +it('sperrt bei neun Fehlversuchen nicht', function () { + $pve = new FakeProxmoxClient; + $pve->guestScripts['nextcloud.log'] = ['out-data' => protokollZeilen('203.0.113.7', 9), 'exitcode' => 0]; + app()->instance(\App\Services\Proxmox\ProxmoxClient::class, $pve); + + aktiveInstanz(); + app(ScanForIntrusions::class)->handle(); + + expect(SecurityBlock::count())->toBe(0); +}); + +it('sperrt nicht, wenn sich die Versuche ueber zwei Fenster verteilen', function () { + // Zehn Versuche sind erst dann zehn, wenn sie im selben Fenster liegen. Wer + // langsam durchprobiert, laeuft absichtlich durch — das ist der Preis + // dafuer, dass ein vertippter Mitarbeiter nicht ausgesperrt wird. + $alt = collect(range(1, 6))->map(fn () => json_encode([ + 'message' => "Login failed: 'admin' (Remote IP: '203.0.113.7')", + 'remoteAddr' => '203.0.113.7', + 'time' => now()->subMinutes(30)->toIso8601String(), + ]))->implode("\n"); + + $pve = new FakeProxmoxClient; + $pve->guestScripts['nextcloud.log'] = ['out-data' => $alt."\n".protokollZeilen('203.0.113.7', 6), 'exitcode' => 0]; + app()->instance(\App\Services\Proxmox\ProxmoxClient::class, $pve); + + aktiveInstanz(); + app(ScanForIntrusions::class)->handle(); + + expect(SecurityBlock::count())->toBe(0); +}); + +it('faengt bei einem rotierten Protokoll wieder bei null an', function () { + // Ist die Datei kleiner als der gemerkte Versatz, wurde rotiert. Ohne diese + // Behandlung liest der nächste Lauf ins Leere und sieht nie wieder etwas. + $pve = new FakeProxmoxClient; + $pve->guestScripts['stat -c %s'] = ['out-data' => "50\n", 'exitcode' => 0]; + $pve->guestScripts['nextcloud.log'] = ['out-data' => protokollZeilen('203.0.113.7', 10), 'exitcode' => 0]; + app()->instance(\App\Services\Proxmox\ProxmoxClient::class, $pve); + + $instance = aktiveInstanz(['security_log_offset' => 999999]); + app(ScanForIntrusions::class)->handle(); + + expect($instance->fresh()->security_log_offset)->toBeLessThan(999999) + ->and(SecurityBlock::count())->toBe(1); +}); + +it('traegt eine noch gueltige Sperre mit der RESTLAUFZEIT wieder ein', function () { + // Nach einem Neustart des Hosts ist die nftables-Menge leer — sie lebt im + // Speicher. Würde die ursprüngliche Dauer erneut gesetzt, verlängerte sich + // eine Sperre bei jedem Neustart. + // + // Uhr eingefroren, UND auf die volle Sekunde — beides zaehlt. Ohne das + // Einfrieren verstreicht zwischen dem Anlegen der Sperre und dem + // Wiedereintragen echte Zeit; ohne die volle Sekunde traegt `now()` + // Mikrosekunden, die `expires_at` nach dem Runden-durch-die-Datenbank + // (die Spalte kennt keine Bruchteile) nicht mehr hat — genau die + // Differenz, um die 1200s sonst auf 1199s abrundet. + Carbon::setTestNow(now()->startOfSecond()); + + $shell = new \App\Services\Ssh\FakeRemoteShell; + app()->instance(\App\Services\Ssh\RemoteShell::class, $shell); + + // Der Auftragszettel legt die Sperre ohne Host an — SecurityBlockFactory + // haengt sie sonst an eine Instanz ohne host_id, und ohne Host gibt es + // nichts, wo HostFirewall::block() etwas eintragen koennte. Mit `forHost` + // (Aufgabe 3) direkt an einem echten Host, wie es eine host_ssh-Sperre + // ohnehin waere. + $host = Host::factory()->active()->create(['ssh_host_key' => 'SHA256:abc']); + $block = SecurityBlock::factory()->forHost($host)->create([ + 'expires_at' => now()->addMinutes(20), + 'blocked_at' => now()->subMinutes(40), + ]); + + app(ScanForIntrusions::class)->handle(); + + expect($shell->ran('timeout 1200s'))->toBeTrue() + ->and($shell->ran('timeout 3600s'))->toBeFalse(); + + Carbon::setTestNow(); +}); + +it('ueberspringt einen Gast, der nicht antwortet, ohne den Versatz zu verlieren', function () { + $pve = new FakeProxmoxClient; + $pve->guestScripts['nextcloud.log'] = ['exitcode' => 1]; + app()->instance(\App\Services\Proxmox\ProxmoxClient::class, $pve); + + $instance = aktiveInstanz(['security_log_offset' => 4711]); + app(ScanForIntrusions::class)->handle(); + + expect($instance->fresh()->security_log_offset)->toBe(4711); +});