274 lines
10 KiB
PHP
274 lines
10 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Security;
|
|
|
|
use App\Mail\SecurityBlockMail;
|
|
use App\Models\Host;
|
|
use App\Models\Instance;
|
|
use App\Models\Operator;
|
|
use App\Models\SecurityBlock;
|
|
use App\Support\ProvisioningSettings;
|
|
use App\Support\Settings;
|
|
use Illuminate\Support\Facades\Mail;
|
|
use Illuminate\Support\Str;
|
|
use Symfony\Component\HttpFoundation\IpUtils;
|
|
use Throwable;
|
|
|
|
/**
|
|
* Die Sperr-Entscheidung: erst die Ausnahmeliste, dann ob schon eine Sperre
|
|
* läuft, dann die Verdopplung bei Wiederholung — und erst danach der
|
|
* Datensatz. Wer die Fehlversuche zählt und diese Klasse aufruft, ist eine
|
|
* eigene, spätere Aufgabe.
|
|
*
|
|
* Ruft HostFirewall::block() auf, sobald ein erreichbarer Host feststeht.
|
|
* Deren Kopfkommentar ist bindend: block() wirft nicht, wenn der Host gerade
|
|
* nicht erreichbar ist, sondern gibt false zurück — und der Datensatz hier
|
|
* entsteht UNABHÄNGIG von diesem Rückgabewert. Eine Sperre, die nur in der
|
|
* Datenbank steht, ist sichtbar und wird beim nächsten Lauf erneut
|
|
* eingetragen (eigene, spätere Aufgabe); eine Ausnahme aus block() würde
|
|
* stattdessen den ganzen Zeitplan-Auftrag mitreißen, der diese Klasse
|
|
* aufruft.
|
|
*/
|
|
class BlockAddress
|
|
{
|
|
/** Erste Sperre. Jede Wiederholung binnen 24h verdoppelt bis zur Obergrenze. */
|
|
private const BASE_SECONDS = 3600;
|
|
|
|
private const MAX_SECONDS = 86400;
|
|
|
|
public function __construct(private HostFirewall $firewall) {}
|
|
|
|
public function forInstance(Instance $instance, string $ip, int $attempts): ?SecurityBlock
|
|
{
|
|
if ($this->isExempt($ip) || $this->hasActiveBlock($ip, instanceId: $instance->id, hostId: null)) {
|
|
return null;
|
|
}
|
|
|
|
$block = $this->createBlock(
|
|
ip: $ip,
|
|
attempts: $attempts,
|
|
reason: 'instance_login',
|
|
instanceId: $instance->id,
|
|
hostId: null,
|
|
firewallHost: $instance->host,
|
|
);
|
|
|
|
$this->notifyInstanceOwner($instance, $block);
|
|
|
|
return $block;
|
|
}
|
|
|
|
public function forHost(Host $host, string $ip, int $attempts): ?SecurityBlock
|
|
{
|
|
if ($this->isExempt($ip) || $this->hasActiveBlock($ip, instanceId: null, hostId: $host->id)) {
|
|
return null;
|
|
}
|
|
|
|
$block = $this->createBlock(
|
|
ip: $ip,
|
|
attempts: $attempts,
|
|
reason: 'host_ssh',
|
|
instanceId: null,
|
|
hostId: $host->id,
|
|
firewallHost: $host,
|
|
);
|
|
|
|
$this->notifyHostManagers($host, $block);
|
|
|
|
return $block;
|
|
}
|
|
|
|
/**
|
|
* Benachrichtigt den Inhaber der Instanz per Mail — höchstens einmal je
|
|
* Instanz und Stunde. Der Zeitpunkt der letzten Meldung steht in
|
|
* `Settings` unter `security.notified.instance.<id>`, nicht in einer
|
|
* eigenen Spalte: nach einer Stunde interessiert er niemanden mehr, und
|
|
* eine Spalte dafür wäre außerhalb dieses einen Fensters immer bedeutungslos.
|
|
*
|
|
* Für Host-Sperren siehe `notifyHostManagers()` weiter unten — andere
|
|
* Empfängerlogik (Betreiber statt Kunde), gleiche Drossel-Idee.
|
|
*
|
|
* Zustellung ist nicht die Bedingung für Schutz: die Sperre steht bereits,
|
|
* bevor hier auch nur versucht wird zu verschicken. Die Drossel-Marke
|
|
* wird erst NACH dem erfolgreichen Einreihen gesetzt, nicht davor —
|
|
* sonst bliebe die Instanz nach einem einzigen fehlgeschlagenen Versuch
|
|
* für den Rest der Stunde ganz ohne Meldung, ausgerechnet dann, wenn statt
|
|
* einer Mail null angekommen sind. Ein Sturm bei dauerhaft kaputtem
|
|
* Postfach ist trotzdem ausgeschlossen: Sperren selbst entstehen schon
|
|
* nur ab der Zehner-Schwelle, nicht bei jedem Fehlversuch.
|
|
*
|
|
* `try/catch` fängt hier nur, was beim EINREIHEN schiefgeht — ein
|
|
* synchroner Fehler, bevor die Mail überhaupt in der Warteschlange liegt
|
|
* (`SecurityBlockMail` implementiert `ShouldQueue`). Was bei der
|
|
* tatsächlichen ZUSTELLUNG schiefgeht, passiert später, in einem
|
|
* Warteschlangen-Arbeiter, lange nachdem diese Methode zurückgekehrt ist,
|
|
* und steht in dessen Protokoll, nicht hier. Die Sperre bleibt in beiden
|
|
* Fällen stehen — das ist die eigentliche Zusage.
|
|
*/
|
|
private function notifyInstanceOwner(Instance $instance, SecurityBlock $block): void
|
|
{
|
|
$key = 'security.notified.instance.'.$instance->id;
|
|
$last = Settings::get($key);
|
|
|
|
if ($last !== null && now()->subHour()->lt($last)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
Mail::to($instance->customer->email)->queue(new SecurityBlockMail($block));
|
|
Settings::set($key, now()->toIso8601String());
|
|
} catch (Throwable $e) {
|
|
report($e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Benachrichtigt jeden AKTIVEN Betreiber mit `hosts.manage` — höchstens
|
|
* einmal je Host und Stunde (`security.notified.host.<id>`, dieselbe
|
|
* Drossel-Idee wie bei Instanzen, eigener Schlüssel).
|
|
*
|
|
* Nicht an eine feste Adresse und nicht an eine neue Einstellung: genau
|
|
* diese Berechtigung entscheidet schon, wer eine Host-Sperre in der
|
|
* Konsole überhaupt aufheben darf (siehe `HostDetail`, `HostTerminal`,
|
|
* `ConfirmRemoveHost`, alle hinter `hosts.manage`) — eine Mail an jemand
|
|
* anderen wäre Lärm, eine an jeden mit dieser Berechtigung ist per
|
|
* Definition der richtige Empfängerkreis, und er pflegt sich von selbst
|
|
* bei jedem Rollenwechsel mit (Koordinator-Entscheidung, siehe Bericht).
|
|
*
|
|
* Kein Empfänger ist kein Fehler: eine frische Installation ohne Admin
|
|
* neben dem Owner, oder eine, in der `hosts.manage` gerade niemandem
|
|
* zugewiesen ist, bekommt schlicht keine Mail — die Sperre selbst steht
|
|
* trotzdem, unabhängig davon.
|
|
*
|
|
* Die Drossel-Marke wird erst gesetzt, wenn mindestens ein Einreihen
|
|
* geglückt ist — aus demselben Grund wie bei `notifyInstanceOwner()`: ein
|
|
* Host, bei dem in einer Stunde ausgerechnet null statt einer Mail
|
|
* ankamen, soll nicht auch noch für den Rest der Stunde verstummen. Jede
|
|
* Adresse steht in ihrem EIGENEN `try/catch`, das nur das EINREIHEN
|
|
* abdeckt (synchron, vor der Warteschlange) — nicht die tatsächliche
|
|
* Zustellung, die später im Warteschlangen-Arbeiter passiert und dort
|
|
* protokolliert wird. Ein einzelnes abgelehntes Postfach darf so weder
|
|
* die übrigen Betreiber um ihre Meldung bringen noch die Marke für alle
|
|
* verhindern, solange wenigstens einer sie bekommen hat.
|
|
*/
|
|
private function notifyHostManagers(Host $host, SecurityBlock $block): void
|
|
{
|
|
$recipients = Operator::query()
|
|
->whereNull('disabled_at')
|
|
->permission('hosts.manage')
|
|
->pluck('email');
|
|
|
|
if ($recipients->isEmpty()) {
|
|
return;
|
|
}
|
|
|
|
$key = 'security.notified.host.'.$host->id;
|
|
$last = Settings::get($key);
|
|
|
|
if ($last !== null && now()->subHour()->lt($last)) {
|
|
return;
|
|
}
|
|
|
|
$queued = false;
|
|
|
|
foreach ($recipients as $email) {
|
|
try {
|
|
Mail::to($email)->queue(new SecurityBlockMail($block));
|
|
$queued = true;
|
|
} catch (Throwable $e) {
|
|
report($e);
|
|
}
|
|
}
|
|
|
|
if ($queued) {
|
|
Settings::set($key, now()->toIso8601String());
|
|
}
|
|
}
|
|
|
|
/** Läuft für diese Adresse an diesem Subjekt schon eine Sperre? */
|
|
private function hasActiveBlock(string $ip, ?int $instanceId, ?int $hostId): bool
|
|
{
|
|
return SecurityBlock::query()
|
|
->where('ip', $ip)
|
|
->when($instanceId !== null, fn ($q) => $q->where('instance_id', $instanceId))
|
|
->when($hostId !== null, fn ($q) => $q->where('host_id', $hostId))
|
|
->active()
|
|
->exists();
|
|
}
|
|
|
|
private function createBlock(
|
|
string $ip,
|
|
int $attempts,
|
|
string $reason,
|
|
?int $instanceId,
|
|
?int $hostId,
|
|
?Host $firewallHost,
|
|
): SecurityBlock {
|
|
$strikes = $this->strikesWithinADay($ip, $instanceId, $hostId) + 1;
|
|
$seconds = min(self::BASE_SECONDS * 2 ** ($strikes - 1), self::MAX_SECONDS);
|
|
$blockedAt = now();
|
|
|
|
$block = SecurityBlock::create([
|
|
'instance_id' => $instanceId,
|
|
'host_id' => $hostId,
|
|
'ip' => $ip,
|
|
'reason' => $reason,
|
|
'attempts' => $attempts,
|
|
'strikes' => $strikes,
|
|
'blocked_at' => $blockedAt,
|
|
'expires_at' => $blockedAt->copy()->addSeconds($seconds),
|
|
]);
|
|
|
|
// Ohne Host (Instanz noch nicht platziert) gibt es nichts einzutragen —
|
|
// der Datensatz steht trotzdem, und eine spätere Zuweisung findet ihn.
|
|
if ($firewallHost !== null) {
|
|
$this->firewall->block($firewallHost, $ip, $seconds);
|
|
}
|
|
|
|
return $block;
|
|
}
|
|
|
|
/**
|
|
* Die wievielte Sperre dieser Adresse an diesem Subjekt in den letzten 24
|
|
* Stunden das hier wird. Zählt JEDE Sperre in dem Fenster, auch eine
|
|
* inzwischen vorzeitig aufgehobene — wer freigibt, hebt die Sperre auf,
|
|
* nicht die Erinnerung daran, dass sie fällig war.
|
|
*/
|
|
private function strikesWithinADay(string $ip, ?int $instanceId, ?int $hostId): int
|
|
{
|
|
return SecurityBlock::query()
|
|
->where('ip', $ip)
|
|
->when($instanceId !== null, fn ($q) => $q->where('instance_id', $instanceId))
|
|
->when($hostId !== null, fn ($q) => $q->where('host_id', $hostId))
|
|
->where('blocked_at', '>=', now()->subDay())
|
|
->count();
|
|
}
|
|
|
|
private function isExempt(string $ip): bool
|
|
{
|
|
return $ip !== '' && IpUtils::checkIp($ip, $this->exemptRanges());
|
|
}
|
|
|
|
/**
|
|
* Die Ausnahmeliste. Hart verdrahtet, ohne Schalter: über das
|
|
* Verwaltungsnetz `10.66.0.0/24` erreicht CluPilot den Host überhaupt —
|
|
* eine Sperre dort wäre das Ende der Fernwartung.
|
|
*
|
|
* @return array<int, string>
|
|
*/
|
|
private function exemptRanges(): array
|
|
{
|
|
$ranges = ['10.66.0.0/24', '127.0.0.1', '::1'];
|
|
|
|
$endpoint = ProvisioningSettings::wgEndpoint();
|
|
|
|
// Leer ist kein Fehler (frische Installation ohne Endpoint) — dann
|
|
// fällt genau dieser eine Eintrag der Liste weg.
|
|
if ($endpoint !== '') {
|
|
$ranges[] = Str::beforeLast($endpoint, ':');
|
|
}
|
|
|
|
return $ranges;
|
|
}
|
|
}
|