CluPilotCloud/app/Provisioning/Jobs/ScanForIntrusions.php

146 lines
5.3 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;
/**
* 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);
}
}
}
}