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