314 lines
13 KiB
PHP
314 lines
13 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']);
|
|
}
|
|
|
|
$this->removeLiftedBlocks($firewall, $perHost);
|
|
}
|
|
|
|
/**
|
|
* Und wieder heraus, was aufgehoben wurde, aber noch drinstehen könnte.
|
|
*
|
|
* `release()` scheitert still, wenn der Host im Moment des Aufhebens nicht
|
|
* erreichbar war — der Datensatz steht dann auf aufgehoben, die Regel aber
|
|
* noch. Ohne diesen Abgleich bliebe die Adresse bis zum Ablauf der
|
|
* ursprünglichen Sperrzeit ausgesperrt, während Portal und Konsole
|
|
* „Aufgehoben" zeigen. Genau die Lücke, die den Menschen ratlos lässt.
|
|
*
|
|
* Betrachtet werden nur aufgehobene Sperren, deren Ablaufzeit noch in der
|
|
* Zukunft liegt: danach hat der Kernel den Eintrag selbst herausgenommen
|
|
* (`flags timeout`), und es gäbe nichts mehr zu tun.
|
|
*
|
|
* Eine Adresse, die eine ANDERE aktive Sperre desselben Hosts noch trägt,
|
|
* bleibt stehen — sonst hebt das Aufheben der einen Sperre die andere
|
|
* gleich mit auf.
|
|
*
|
|
* @param array<int, array{host: Host, seconds: array<string, int>}> $perHost die soeben eingetragenen
|
|
*/
|
|
private function removeLiftedBlocks(HostFirewall $firewall, array $perHost): void
|
|
{
|
|
$lifted = SecurityBlock::query()
|
|
->whereNotNull('released_at')
|
|
->where('expires_at', '>', now())
|
|
->with(['host', 'instance.host'])
|
|
->get();
|
|
|
|
/** @var array<int, array{host: Host, ips: array<string, string>}> */
|
|
$perHostRemove = [];
|
|
|
|
foreach ($lifted as $block) {
|
|
$host = $block->host ?? $block->instance?->host;
|
|
|
|
if ($host === null || isset($perHost[$host->id]['seconds'][$block->ip])) {
|
|
continue;
|
|
}
|
|
|
|
$perHostRemove[$host->id] ??= ['host' => $host, 'ips' => []];
|
|
$perHostRemove[$host->id]['ips'][$block->ip] = $block->ip; // je Adresse einmal
|
|
}
|
|
|
|
foreach ($perHostRemove as $entry) {
|
|
$firewall->releaseMany($entry['host'], array_values($entry['ips']));
|
|
}
|
|
}
|
|
}
|