CluPilotCloud/app/Provisioning/Jobs/ScanForIntrusions.php

266 lines
11 KiB
PHP

<?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;
use Illuminate\Support\Facades\RateLimiter;
use Throwable;
/**
* 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.
*
* Die Zusage lautet "10 Fehlversuche in 10 Minuten" — nicht "10 Fehlversuche
* im Zuwachs eines einzelnen Laufs". Jeder Lauf liest von `FailedLoginReader`
* nur, was seit dem letzten Mal neu dazugekommen ist (typisch: eine Minute
* Protokoll), und wer das direkt gegen die Schwelle hielte, würde aus "10 in
* 10 Minuten" faktisch "10 in einer Minute" machen — ein Angreifer mit drei
* Versuchen je Minute käme auf dreißig in zehn Minuten und liefe nie über die
* Schwelle. Deshalb wandert jeder frisch gelesene Treffer in einen
* Zählstand je Subjekt und Adresse, der über Läufe hinweg hält und nach zehn
* Minuten von selbst verfällt (`RateLimiter`, cache-gestützt, wie
* `App\Livewire\Auth\OperatorLogin` es für Anmeldeversuche schon vormacht) —
* erst DER Stand wird gegen die Schwelle gehalten.
*
* 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;
/**
* Das Fenster, ueber das der Zaehlstand je Subjekt und Adresse haelt —
* dieselben zehn Minuten wie die Schwelle selbst, nur jetzt als
* Cache-Ablaufzeit statt als Zeitstempel-Filter innerhalb einer einzelnen
* Protokoll-Zeile (das bleibt zusaetzlich in FailedLoginReader, gegen ein
* lange nicht gelesenes Protokoll).
*/
private const WINDOW_SECONDS = 600;
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) {
// Wie PingHosts nebenan: je Instanz einzeln umschlossen, nicht die
// ganze Schleife in einem Block. `FailedLoginReader::fromInstance()`
// fängt zwar schon jede Ausnahme von `guestExec()` selbst ab
// (Fix-Runde 2: es tat das vorher NICHT, und eine einzelne
// abgeschaltete VM riss den ganzen Lauf mit — die übrigen
// Instanzen wurden nicht mehr gelesen, scanHosts() lief nicht,
// und vor allem reapplyActiveBlocks() lief nicht, jede Minute
// erneut). Dieser zweite Ring bleibt als Netz für alles andere in
// dieser Runde (BlockAddress, save()), aus demselben Grund.
try {
$result = $reader->fromInstance($instance);
// Gast antwortet nicht — kein Vorfall, Versatz bleibt unangetastet.
if ($result === null) {
continue;
}
$subject = 'instance:'.$instance->id;
foreach ($result['addresses'] as $ip => $freshAttempts) {
$total = $this->accumulate($subject, $ip, $freshAttempts);
if ($total >= self::THRESHOLD) {
$blocker->forInstance($instance, $ip, $total);
$this->resetAccumulator($subject, $ip);
}
}
// Nur bei Erfolg speichern — genau die Bytes, die wirklich
// gelesen wurden.
$instance->forceFill(['security_log_offset' => $result['offset']])->save();
} catch (Throwable) {
// Absichtlich still, wie PingHosts: der Cursor bleibt
// unangetastet, der nächste Lauf versucht es wieder.
}
}
}
private function scanHosts(FailedLoginReader $reader, BlockAddress $blocker): void
{
$hosts = Host::query()
->whereNotNull('wg_ip')
->whereNotNull('ssh_host_key')
->get();
foreach ($hosts as $host) {
// Derselbe Ring wie bei den Instanzen nebenan, aus demselben Grund
// (Fix-Runde 2 hat ihn dort eingezogen, diese Methode ging leer
// aus). `FailedLoginReader::fromHost()` fängt seine eigene
// SSH-Ausnahme zwar schon ab, aber alles danach nicht:
// `SecurityBlock::create()`, `Settings::set()` und die
// Empfängersuche in `BlockAddress` (Spaties
// `PermissionDoesNotExist`, wenn `hosts.manage` fehlt), dazu
// `$host->save()`. Wirft eines davon, bricht der ganze Lauf ab —
// die übrigen Hosts werden nicht mehr gelesen, und vor allem läuft
// `reapplyActiveBlocks()` nicht, jede Minute erneut.
try {
$result = $reader->fromHost($host);
if ($result === null) {
continue;
}
$subject = 'host:'.$host->id;
foreach ($result['addresses'] as $ip => $freshAttempts) {
$total = $this->accumulate($subject, $ip, $freshAttempts);
if ($total >= self::THRESHOLD) {
$blocker->forHost($host, $ip, $total);
$this->resetAccumulator($subject, $ip);
}
}
$host->forceFill(['security_log_seen_at' => $result['seenAt']])->save();
} catch (Throwable) {
// Absichtlich still, wie bei den Instanzen: der Zeitpunkt
// bleibt unangetastet, der nächste Lauf versucht es wieder.
}
}
}
/**
* Addiert frisch gelesene Treffer auf den laufübergreifenden Stand und
* gibt den neuen Gesamtstand zurück. Der Schlüssel trägt Subjekt UND
* Adresse: zwei Instanzen, die von derselben Adresse Versuche sehen,
* dürfen sich nicht gegenseitig hochzählen, und eine Adresse darf nicht
* von den Versuchen einer anderen mitgerissen werden.
*/
private function accumulate(string $subject, string $ip, int $freshAttempts): int
{
return RateLimiter::increment($this->tallyKey($subject, $ip), self::WINDOW_SECONDS, $freshAttempts);
}
/**
* Nach dem Sperren zurücksetzen. Ohne das stünde der Stand weiter bei der
* Schwelle, und sobald die Sperre abläuft oder der Inhaber sie aufhebt,
* löste der nächste einzelne Fehlversuch sofort die nächste Sperre aus.
*/
private function resetAccumulator(string $subject, string $ip): void
{
RateLimiter::clear($this->tallyKey($subject, $ip));
}
private function tallyKey(string $subject, string $ip): string
{
return 'intrusion-scan:'.$subject.':'.$ip;
}
/**
* Alle noch gültigen Sperren erneut eintragen — je Host EIN Aufruf, EINE
* Verbindung.
*
* Vorher lief das je Sperre einzeln, und jede Eintragung baute ihre eigene
* SSH-Sitzung auf. Bei dreissig Sperren waren das dreissig Handshakes pro
* Minute, seriell, auf dem einzigen Provisionierungs-Arbeiter — und
* dreissig Sperren gibt es genau dann, wenn gerade angegriffen wird,
* während derselbe Arbeiter die bezahlte Kundenbereitstellung fährt.
*
* Die RESTLAUFZEIT je Sperre, nicht die ursprüngliche Dauer, und auch
* nicht eine gemeinsame fürs ganze Bündel: `HostFirewall::blockMany()`
* nimmt sie einzeln entgegen. Sonst verlängerte sich jede Sperre bei jedem
* Neustart des Hosts.
*/
private function reapplyActiveBlocks(HostFirewall $firewall): void
{
$blocks = SecurityBlock::active()->with(['host', 'instance.host'])->get();
/** @var array<int, array{host: Host, seconds: array<string, int>}> */
$perHost = [];
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) {
continue;
}
$perHost[$host->id] ??= ['host' => $host, 'seconds' => []];
// Dieselbe Adresse an zwei Subjekten desselben Hosts (Instanz und
// Host, oder zwei Instanzen) ist EIN Element in der Menge. Die
// längere Restlaufzeit gewinnt — die kürzere zuletzt einzutragen
// würde die längere Sperre verkürzen.
$perHost[$host->id]['seconds'][$block->ip] =
max($perHost[$host->id]['seconds'][$block->ip] ?? 0, $seconds);
}
foreach ($perHost as $entry) {
$firewall->blockMany($entry['host'], $entry['seconds']);
}
}
}