Melder: gescheiterte Anmeldungen lesen, zaehlen, sperren

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.
feat/versandtakt
nexxo 2026-08-03 13:49:34 +02:00
parent 09cb8aea5c
commit d3407ff613
6 changed files with 539 additions and 2 deletions

View File

@ -0,0 +1,145 @@
<?php
namespace App\Provisioning\Jobs;
use App\Models\Host;
use App\Models\Instance;
use App\Models\SecurityBlock;
use App\Services\Security\BlockAddress;
use App\Services\Security\FailedLoginReader;
use App\Services\Security\HostFirewall;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
/**
* Der Melder: liest gescheiterte Anmeldungen von jeder aktiven Instanz und
* jedem eingerichteten Host, sperrt ab der Schwelle über `BlockAddress`, und
* trägt danach jede noch gültige Sperre erneut in die Firewall ein.
*
* Das Wiedereintragen ist kein Aufräumen nebenbei: die nftables-Menge lebt im
* Speicher des Hosts, ein Neustart leert sie, während unsere Datenbank die
* Sperre weiterführt. Mit der RESTLAUFZEIT (`now()` bis `expires_at`), nicht
* der ursprünglichen Dauer sonst verlängerte sich jede Sperre bei jedem
* Neustart des Hosts.
*
* Läuft auf der `provisioning`-Warteschlange: nur deren Arbeiter hängt im
* WireGuard-Tunnel und erreicht Hosts und Gäste überhaupt (routes/console.php).
* Jede Minute, billig, wenn nichts zu tun ist es wird nur der Zuwachs seit
* dem letzten Mal gelesen.
*/
class ScanForIntrusions implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/** Ab wie vielen Fehlversuchen im Fenster gesperrt wird. */
private const THRESHOLD = 10;
public function __construct()
{
// Nicht als `public $queue`-Eigenschaft: die kollidiert mit der
// gleichnamigen, aber anders deklarierten Eigenschaft aus dem
// Queueable-Trait (kein Default dort) — ein fataler
// Kompositionsfehler. onQueue() ist der Weg, den jeder andere
// Auftrag in diesem Verzeichnis schon geht (siehe PingHosts,
// SyncVpnPeers, CollectHostLoad).
$this->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);
}
}
}
}

View File

@ -71,8 +71,16 @@ class FakeProxmoxClient implements ProxmoxClient
/** @var array<int, string> recorded guest commands */ /** @var array<int, string> recorded guest commands */
public array $guestCommands = []; public array $guestCommands = [];
/** @var array<string, array{exitcode:int,out-data:string}> */ /**
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<string, array{exitcode?:int,out-data?:string}>
*/
public array $guestScripts = [];
public int $guestDefaultExit = 0; public int $guestDefaultExit = 0;

View File

@ -0,0 +1,212 @@
<?php
namespace App\Services\Security;
use App\Models\Host;
use App\Models\Instance;
use App\Services\Proxmox\ProxmoxClient;
use App\Services\Secrets\SecretVault;
use App\Services\Ssh\RemoteShell;
use App\Support\NextcloudOcc;
use Illuminate\Support\Carbon;
use Throwable;
/**
* Liest gescheiterte Anmeldungen aus zwei Quellen beide nur LESEND, beide
* über den Weg, den das Produkt für dieses Subjekt schon benutzt: eine
* Kundeninstanz über den Proxmox-Gastagenten (wie jeder occ-Aufruf im
* Produkt), ein Host über SSH durch den Tunnel (wie HostFirewall).
*
* Zählt nicht selbst gegen die Schwelle das ist Sache des Aufrufers
* (ScanForIntrusions). Diese Klasse liefert je Lauf nur, was seit dem letzten
* Mal an NEUEM dazugekommen ist, innerhalb des Zeitfensters, plus den Cursor,
* an dem der nächste Lauf weiterlesen muss.
*
* Ein Gast oder Host, der nicht antwortet, ist KEIN Vorfall: beide Methoden
* geben dann null zurück, statt zu werfen oder einen leeren Treffer
* vorzutäuschen der Aufrufer lässt den Cursor in diesem Fall unangetastet.
*/
class FailedLoginReader
{
/** Nur Zeilen, die höchstens so alt sind, zählen gegen die Schwelle. */
private const WINDOW_MINUTES = 10;
public function __construct(
private ProxmoxClient $pve,
private RemoteShell $shell,
) {}
/**
* Nextclouds eigene Protokolldatei, ab dem gemerkten Byte-Versatz.
*
* @return array{offset: int, addresses: array<string, int>}|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<string, int>}|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<int, array{ip: string, time: Carbon}>
*/
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<int, array{ip: string, time: Carbon}> $entries
* @return array<string, int>
*/
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<string, int>
*/
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;
}
}

View File

@ -60,4 +60,16 @@ final class NextcloudOcc
return 'cd '.self::DIRECTORY.' && '.$assignments return 'cd '.self::DIRECTORY.' && '.$assignments
.'docker compose exec -T -u '.self::USER.' '.$forwards.'app php occ '.$arguments; .'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;
}
} }

View File

@ -6,6 +6,7 @@ use App\Provisioning\Jobs\CollectHostLoad;
use App\Provisioning\Jobs\CollectInstanceTraffic; use App\Provisioning\Jobs\CollectInstanceTraffic;
use App\Provisioning\Jobs\PingHosts; use App\Provisioning\Jobs\PingHosts;
use App\Provisioning\Jobs\RecordProvisioningHeartbeat; use App\Provisioning\Jobs\RecordProvisioningHeartbeat;
use App\Provisioning\Jobs\ScanForIntrusions;
use App\Provisioning\Jobs\SyncMonitoringStatus; use App\Provisioning\Jobs\SyncMonitoringStatus;
use App\Provisioning\Jobs\SyncVpnPeers; use App\Provisioning\Jobs\SyncVpnPeers;
use App\Support\Settings; use App\Support\Settings;
@ -265,3 +266,10 @@ Schedule::command('clupilot:check-certificates')
->dailyAt('04:17') ->dailyAt('04:17')
->withoutOverlapping() ->withoutOverlapping()
->name('check-certificates'); ->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();

View File

@ -0,0 +1,152 @@
<?php // tests/Feature/Security/ScanForIntrusionsTest.php
use App\Models\Host;
use App\Models\Instance;
use App\Models\SecurityBlock;
use App\Provisioning\Jobs\ScanForIntrusions;
use App\Services\Proxmox\FakeProxmoxClient;
use App\Services\Ssh\FakeRemoteShell;
use App\Services\Ssh\RemoteShell;
use Illuminate\Support\Carbon;
beforeEach(function () {
// Jede Instanz hat unten (aktiveInstanz()) einen echten Host — also kann
// BlockAddress ueber HostFirewall auch in Tests, denen es nur ums Lesen
// geht, eine SSH-Verbindung versuchen. Ohne einen gebundenen Fake liefe
// das gegen eine echte, nicht erreichbare Adresse und haengt am
// TCP-Verbindungsaufbau, statt sofort false zurueckzugeben. Tests, die den
// Shell-Verkehr selbst pruefen, binden ihren eigenen Fake und ueberschreiben
// diesen hier.
app()->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);
});