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 $seconds Adresse => Restlaufzeit in Sekunden */ public function blockMany(Host $host, array $seconds): bool { /** @var array> */ $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 { return $this->releaseMany($host, [$ip]); } /** * Mehrere Adressen desselben Hosts wieder heraus, in EINER SSH-Sitzung — * das Gegenstück zu blockMany(), aus demselben Grund. * * `|| true` hinter jedem Befehl ist hier kein Wegsehen, sondern die * richtige Bedeutung: `nft delete element` scheitert, wenn das Element * schon weg ist — und weg ist genau das Ziel. Der Kernel nimmt abgelaufene * Einträge selbst heraus (`flags timeout`), und eine Sperre, die wegen * eines nicht erreichbaren Hosts nie eingetragen wurde, kann man auch * nicht löschen. Ohne dieses `|| true` meldete der Abgleich unten bei * JEDEM Lauf einen Fehlschlag, und der Mensch am Bildschirm bekäme * „Server nicht erreichbar" zu lesen, während in Wahrheit alles stimmt. * * Was bleibt, ist die Aussage, um die es geht: `false` heißt, der Host war * nicht erreichbar — das wirft `connectWithKey()`, und `apply()` fängt es. * * @param array $ips */ public function releaseMany(Host $host, array $ips): bool { /** @var array> */ $elements = []; foreach ($ips as $ip) { if (! $this->isWellFormed($host, $ip)) { return false; } $elements[$this->setFor($ip)][] = $ip; } if ($elements === []) { return true; } $commands = []; foreach ($elements as $set => $entries) { $commands[] = sprintf( 'nft delete element inet clupilot_filter %s { %s } 2>/dev/null || true', $set, implode(', ', $entries), ); } return $this->apply($host, $commands, implode(', ', $ips)); } /** * 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 $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; } }