From 77a4c3d9902442c803be83c0239a710743c78e0d Mon Sep 17 00:00:00 2001 From: nexxo Date: Mon, 3 Aug 2026 12:55:34 +0200 Subject: [PATCH] Sperrliste in der Host-Firewall, unter der Regel fuer bestehende Verbindungen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zwei nftables-Mengen (clupilot_blocked/clupilot_blocked6, beide mit flags timeout) im erzeugten Regelwerk, die Drop-Regel dafuer sitzt absichtlich unter ct state established,related accept — wer drin ist, bleibt drin, gesperrt wird nur, was neu anklopft. HostFirewall::block()/ release() tragen eine Adresse mit Ablaufzeit ein bzw. nehmen sie heraus, ueber die WireGuard-Adresse des Hosts, und geben false statt zu werfen, wenn der Host nicht erreichbar ist, damit eine spaetere Wiedereintrage- Aufgabe die Sperre einfach nochmal versuchen kann. --- .../Steps/Host/SecureHostFirewall.php | 21 +++++ app/Services/Security/HostFirewall.php | 77 +++++++++++++++++++ tests/Feature/Security/HostFirewallTest.php | 56 ++++++++++++++ 3 files changed, 154 insertions(+) create mode 100644 app/Services/Security/HostFirewall.php create mode 100644 tests/Feature/Security/HostFirewallTest.php diff --git a/app/Provisioning/Steps/Host/SecureHostFirewall.php b/app/Provisioning/Steps/Host/SecureHostFirewall.php index 9cd897c..705b13d 100644 --- a/app/Provisioning/Steps/Host/SecureHostFirewall.php +++ b/app/Provisioning/Steps/Host/SecureHostFirewall.php @@ -123,6 +123,20 @@ class SecureHostFirewall extends HostStep flush ruleset table inet clupilot_filter { + # Adressen, die gerade gesperrt sind. Die Option `timeout` in den `flags` + # ist nicht schmückend: ohne sie nimmt nftables beim Eintragen gar keine + # Zeitangabe an — und die Frist liefe dann nur in unserer Datenbank ab, + # nicht im Kernel. + set clupilot_blocked { + type ipv4_addr + flags timeout + } + + set clupilot_blocked6 { + type ipv6_addr + flags timeout + } + chain input { type filter hook input priority 0; policy drop; @@ -130,6 +144,13 @@ table inet clupilot_filter { ct state invalid drop ct state established,related accept + # UNTER der Zeile darüber, und das ist die ganze Zusage dieses Systems: + # wer schon verbunden ist, bleibt verbunden. Gesperrt wird nur, was neu + # anklopft. Stünde diese Regel eine Zeile höher, flöge jeder mitten aus + # seiner Sitzung — auch der, den es gar nicht meint. + ip saddr @clupilot_blocked drop + ip6 saddr @clupilot_blocked6 drop + # ICMPv6 is not optional. Neighbour discovery (135/136) and router # advertisements arrive in the INPUT chain, so a bare policy drop takes # IPv6 down on this host as soon as the neighbour cache expires — minutes, diff --git a/app/Services/Security/HostFirewall.php b/app/Services/Security/HostFirewall.php new file mode 100644 index 0000000..629f98d --- /dev/null +++ b/app/Services/Security/HostFirewall.php @@ -0,0 +1,77 @@ +apply($host, sprintf( + 'nft add element inet clupilot_filter %s { %s timeout %ds }', + $this->setFor($ip), + $ip, + $seconds, + )); + } + + public function release(Host $host, string $ip): bool + { + return $this->apply($host, sprintf( + 'nft delete element inet clupilot_filter %s { %s }', + $this->setFor($ip), + $ip, + )); + } + + /** 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'; + } + + private function apply(Host $host, string $command): bool + { + try { + $this->shell->connectWithKey( + $host->wg_ip, + 'root', + (string) app(SecretVault::class)->get('ssh.private_key'), + $host->ssh_host_key, // gepinnt bei EstablishSshTrust + ); + + return $this->shell->run($command)->ok(); + } catch (Throwable) { + return false; + } + } +} diff --git a/tests/Feature/Security/HostFirewallTest.php b/tests/Feature/Security/HostFirewallTest.php new file mode 100644 index 0000000..fc057cf --- /dev/null +++ b/tests/Feature/Security/HostFirewallTest.php @@ -0,0 +1,56 @@ +instance(\App\Services\Ssh\RemoteShell::class, $shell); + + app(SecureHostFirewall::class)->execute( + \App\Models\ProvisioningRun::factory()->forHost(Host::factory()->create())->create() + ); + + $regelwerk = $shell->files()['/etc/nftables.conf'] ?? ''; + + $established = strpos($regelwerk, 'ct state established,related accept'); + $sperre = strpos($regelwerk, '@clupilot_blocked'); + + expect($established)->not->toBeFalse() + ->and($sperre)->not->toBeFalse() + ->and($established)->toBeLessThan($sperre); + + // Beide Mengen, und beide mit Ablaufzeit — ohne `flags timeout` nimmt + // nftables die Zeitangabe beim Eintragen gar nicht an. + expect($regelwerk)->toContain('set clupilot_blocked') + ->and($regelwerk)->toContain('set clupilot_blocked6') + ->and(substr_count($regelwerk, 'flags timeout'))->toBe(2); +}); + +it('traegt eine Adresse mit Ablaufzeit ein und nimmt sie wieder heraus', function () { + $shell = new FakeRemoteShell; + app()->instance(\App\Services\Ssh\RemoteShell::class, $shell); + $host = Host::factory()->active()->create(['ssh_host_key' => 'SHA256:abc']); + + app(HostFirewall::class)->block($host, '203.0.113.7', 3600); + app(HostFirewall::class)->release($host, '203.0.113.7'); + + expect($shell->ran('add element inet clupilot_filter clupilot_blocked { 203.0.113.7 timeout 3600s }'))->toBeTrue() + ->and($shell->ran('delete element inet clupilot_filter clupilot_blocked { 203.0.113.7 }'))->toBeTrue(); +}); + +it('waehlt fuer eine IPv6-Adresse die zweite Menge', function () { + $shell = new FakeRemoteShell; + app()->instance(\App\Services\Ssh\RemoteShell::class, $shell); + $host = Host::factory()->active()->create(['ssh_host_key' => 'SHA256:abc']); + + app(HostFirewall::class)->block($host, '2001:db8::1', 3600); + + expect($shell->ran('clupilot_blocked6 { 2001:db8::1 timeout 3600s }'))->toBeTrue(); +});