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
parent
09cb8aea5c
commit
d3407ff613
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue