CluPilotCloud/app/Services/Security/BlockAddress.php

253 lines
9.0 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, und die Drossel wird
* VOR dem Versandversuch gesetzt, nicht danach — sonst würde ein
* dauerhaft kaputtes Postfach bei jeder weiteren Sperre an derselben
* Instanz erneut versuchen und denselben Fehler immer wieder melden. Ein
* Throwable beim Verschicken wird über `report()` gemeldet und
* verschluckt: ein kaputtes Postfach darf die Sperre nicht rückgängig
* machen, die es eigentlich ankündigen sollte.
*/
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;
}
Settings::set($key, now()->toIso8601String());
try {
Mail::to($instance->customer->email)->queue(new SecurityBlockMail($block));
} 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.
*/
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;
}
Settings::set($key, now()->toIso8601String());
// Einzeln umschlossen: ein Postfach, das ablehnt, darf die übrigen
// Betreiber nicht auch noch um ihre Meldung bringen.
foreach ($recipients as $email) {
try {
Mail::to($email)->queue(new SecurityBlockMail($block));
} catch (Throwable $e) {
report($e);
}
}
}
/** 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;
}
}