CluPilotCloud/app/Services/Security/BlockAddress.php

343 lines
13 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\DnsLookup;
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;
/** @var array<int, string>|null einmal aufgelöst, siehe exemptRanges() */
private ?array $exempt = null;
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 = new SecurityBlockMail($block);
Mail::mailer($mail->mailer)->to($instance->customer->email)->queue($mail);
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 = new SecurityBlockMail($block);
Mail::mailer($mail->mailer)->to($email)->queue($mail);
$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.
*
* Einmal je Instanz dieser Klasse aufgelöst und dann gemerkt: ein Lauf
* holt sich `BlockAddress` einmal aus dem Container und fragt damit jede
* gesichtete Adresse, und der Endpunkt-Eintrag unten kann eine
* Namensauflösung kosten.
*
* @return array<int, string>
*/
private function exemptRanges(): array
{
return $this->exempt ??= array_merge(
['10.66.0.0/24', '127.0.0.1', '::1'],
$this->ownServerAddresses(),
);
}
/**
* Die eigene öffentliche Adresse des CluPilot-Servers, aus dem
* WireGuard-Endpunkt.
*
* Der Endpunkt ist `host:port`, und `host` darf ein NAME sein — genau so
* steht es in `.env.example` (`vpn.clupilot.com:51820`). `IpUtils::checkIp()`
* sagt zu einem Namen immer `false`, also war die eigene Adresse in
* dokumentierter Konfiguration still NICHT ausgenommen: der Server hätte
* sich selbst aussperren können. Bei IPv6 blieben zusätzlich die Klammern
* stehen (`[2001:db8::1]`), mit demselben Ergebnis.
*
* Schlägt die Auflösung fehl, ist das kein Fehler — dann fällt der Eintrag
* weg wie bei leerem Endpunkt. Ein Name, der gerade nicht auflöst, darf
* nicht dazu führen, dass gar nichts mehr gesperrt wird.
*
* @return array<int, string>
*/
private function ownServerAddresses(): array
{
$endpoint = trim(ProvisioningSettings::wgEndpoint());
// Leer ist kein Fehler (frische Installation ohne Endpoint) — dann
// fällt genau dieser eine Eintrag der Liste weg.
if ($endpoint === '') {
return [];
}
$host = $this->hostPartOf($endpoint);
if ($host === '') {
return [];
}
// Schon eine Adresse: nichts aufzulösen. Deckt beide Schreibweisen ab,
// die hostPartOf() liefern kann — die entklammerte IPv6 und die
// gewöhnliche IPv4.
if (filter_var($host, FILTER_VALIDATE_IP) !== false) {
return [$host];
}
// Ein Name steht für ALLE seine Adressen. Nur die erste zu nehmen
// hieße, den Server bei einem Wechsel im Round-Robin doch wieder
// aussperren zu können.
return app(DnsLookup::class)->addressesFor($host);
}
/**
* Der Wirt aus `wirt:port` — mit den drei Formen, die wirklich vorkommen:
* `1.2.3.4:51820`, `[2001:db8::1]:51820` und `vpn.example.com:51820`.
* Eine nackte IPv6 ohne Klammern und ohne Port (`2001:db8::1`) bleibt
* ganz, weil `beforeLast(':')` sie sonst mitten im Wort abschnitte.
*/
private function hostPartOf(string $endpoint): string
{
if (preg_match('/^\[(.+)\](?::\d+)?$/', $endpoint, $match) === 1) {
return $match[1];
}
if (filter_var($endpoint, FILTER_VALIDATE_IP) !== false) {
return $endpoint;
}
return str_contains($endpoint, ':') ? Str::beforeLast($endpoint, ':') : $endpoint;
}
}