227 lines
8.5 KiB
PHP
227 lines
8.5 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
|
|
{
|
|
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<int, string> $ips
|
|
*/
|
|
public function releaseMany(Host $host, array $ips): bool
|
|
{
|
|
/** @var array<string, array<int, string>> */
|
|
$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<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;
|
|
}
|
|
}
|