|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.`, 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.`, 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. * * 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 */ 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 */ 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; } }