CluPilotCloud/app/Services/Security/HostFirewall.php

187 lines
7.0 KiB
PHP

<?php
namespace App\Services\Security;
use App\Models\Host;
use App\Services\Secrets\SecretVault;
use App\Services\Ssh\RemoteShell;
use RuntimeException;
use Throwable;
/**
* Trägt eine Adresse in die Sperrmenge eines Hosts ein oder nimmt sie wieder
* heraus — die beiden nftables-Mengen `clupilot_blocked` (IPv4) und
* `clupilot_blocked6` (IPv6), die SecureHostFirewall im Regelwerk anlegt.
* `flags timeout` an beiden Mengen (siehe dort) heißt: die Ablaufzeit geben
* wir hier mit, und der Kernel selbst nimmt die Adresse wieder heraus — kein
* eigener Aufräum-Lauf nötig für den Normalfall.
*
* Wer eine Sperre AUSLÖST, gehört nicht hierher — das hier ist nur der Griff:
* eintragen, herausnehmen.
*
* Baut die Verbindung direkt über die WireGuard-Adresse des Hosts auf, nicht
* über HostStep::keyLogin()s Rückfallpfad auf die öffentliche Adresse: diese
* Klasse läuft ausschließlich gegen bereits aktive Hosts, deren Tunnel längst
* bewiesen ist, nie während der Inbetriebnahme selbst.
*
* block() und release() werfen absichtlich NICHT, wenn der Host nicht
* erreichbar ist — sie geben `false` zurück. `BlockAddress` legt den
* Sperrdatensatz trotzdem an und `ScanForIntrusions` trägt ihn beim nächsten
* Lauf erneut ein (Wiedereintragen). Eine Sperre, die nur in der Datenbank
* steht, ist sichtbar und wird nachgeholt; eine Ausnahme hier würde
* stattdessen den ganzen Zeitplan-Auftrag mitreißen, der diese Methode
* aufruft.
*
* `false` ist deshalb kein Grund zum Werfen, aber sehr wohl einer zum
* MELDEN: auf einem Host, der sein Regelwerk noch ohne die Mengen trägt,
* scheitert `nft add element` bei JEDEM Versuch, und beide Aufrufer verwerfen
* den Rückgabewert. Die Sperre stünde dann in Datenbank, Portal, Konsole und
* in der Mail an den Kunden als aktiv und in der Firewall nie. Darum meldet
* `apply()` jeden Misserfolg selbst — an der einen Stelle, an der er anfällt,
* statt an jedem Aufrufer, der ihn vergessen kann. Nachziehen lässt sich so
* ein Host mit `php artisan clupilot:refresh-host-firewall`.
*
* Jede Adresse geht vor dem `sprintf` durch `filter_var(…, FILTER_VALIDATE_IP)`.
* Das ist kein doppelter Boden für einen ohnehin sauberen Aufrufer, sondern
* die Bedingung dafür, dass dieser Dienst überhaupt eine Wurzel-Shell füttern
* darf: `PhpseclibRemoteShell::run()` reicht die zusammengebaute Zeile an
* `/bin/sh` als root auf dem Proxmox-Host, und ein Semikolon in der Adresse
* wäre dort ein zweiter Befehl. Der Leser prüft dasselbe (FailedLoginReader);
* ein Dienst, der eine Wurzel-Shell füttert, darf sich nicht darauf verlassen,
* dass sein Aufrufer sauber war.
*/
class HostFirewall
{
public function __construct(private RemoteShell $shell) {}
public function block(Host $host, string $ip, int $seconds): bool
{
return $this->blockMany($host, [$ip => $seconds]);
}
/**
* Mehrere Adressen desselben Hosts in EINER SSH-Sitzung.
*
* `ScanForIntrusions::reapplyActiveBlocks()` trug bisher je aktiver Sperre
* einzeln ein, und jede Eintragung baute ihre eigene Verbindung auf: bei
* dreissig Sperren dreissig Handshakes pro Minute, seriell, auf dem
* einzigen Provisionierungs-Arbeiter — also genau WÄHREND eines Angriffs
* auf demselben Arbeiter, der die bezahlte Kundenbereitstellung fährt.
*
* `nft` nimmt mehrere Elemente in einem Aufruf: `{ a timeout 60s, b
* timeout 90s }`. Je Adressfamilie einer, weil v4 und v6 in getrennten
* Mengen leben — also höchstens zwei Befehle, eine Verbindung.
*
* Jede Adresse behält ihre EIGENE Restlaufzeit. Eine gemeinsame Dauer für
* das ganze Bündel wäre bequemer und würde genau die Zusicherung
* verwässern, für die es einen eigenen Test gibt.
*
* @param array<string, int> $seconds Adresse => Restlaufzeit in Sekunden
*/
public function blockMany(Host $host, array $seconds): bool
{
/** @var array<string, array<int, string>> */
$elements = [];
foreach ($seconds as $ip => $duration) {
$ip = (string) $ip;
if (! $this->isWellFormed($host, $ip)) {
return false;
}
$elements[$this->setFor($ip)][] = sprintf('%s timeout %ds', $ip, (int) $duration);
}
if ($elements === []) {
return true;
}
$commands = [];
foreach ($elements as $set => $entries) {
$commands[] = sprintf(
'nft add element inet clupilot_filter %s { %s }',
$set,
implode(', ', $entries),
);
}
return $this->apply($host, $commands, implode(', ', array_keys($seconds)));
}
public function release(Host $host, string $ip): bool
{
if (! $this->isWellFormed($host, $ip)) {
return false;
}
return $this->apply($host, [sprintf(
'nft delete element inet clupilot_filter %s { %s }',
$this->setFor($ip),
$ip,
)], $ip);
}
/**
* Wohlgeformt genug, um in eine Wurzel-Shell zu wandern?
*
* Meldet und verweigert statt still weiterzumachen: eine Adresse, die hier
* ankommt und keine ist, ist entweder ein Versuch oder ein Fehler weiter
* oben — beides will man sehen.
*/
private function isWellFormed(Host $host, string $ip): bool
{
if (filter_var($ip, FILTER_VALIDATE_IP) !== false) {
return true;
}
report(new RuntimeException(
"HostFirewall: abgewiesene Adresse [{$ip}] fuer Host [{$host->name}] — keine gueltige IP, nichts ausgefuehrt."
));
return false;
}
/** Die Adressfamilie entscheidet über die Menge — v4 und v6 leben getrennt. */
private function setFor(string $ip): string
{
return str_contains($ip, ':') ? 'clupilot_blocked6' : 'clupilot_blocked';
}
/**
* @param array<int, string> $commands alle in EINER Sitzung
*/
private function apply(Host $host, array $commands, string $subject): bool
{
try {
$this->shell->connectWithKey(
$host->wg_ip,
'root',
(string) app(SecretVault::class)->get('ssh.private_key'),
$host->ssh_host_key, // gepinnt bei EstablishSshTrust
);
$ok = true;
foreach ($commands as $command) {
// Nicht abbrechen beim ersten Fehlschlag: die Verbindung steht
// ohnehin schon, und v6 soll nicht deshalb ausfallen, weil v4
// gerade nicht ging.
$ok = $this->shell->run($command)->ok() && $ok;
}
} catch (Throwable) {
$ok = false;
}
if (! $ok) {
report(new RuntimeException(
"HostFirewall: [{$subject}] auf Host [{$host->name}] nicht in die Firewall geschrieben "
.'— der Sperrdatensatz steht, die Regel nicht.'
));
}
return $ok;
}
}