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. Strukturell geprüft, // je Menge einzeln: ein bloßer substr_count('flags timeout') über den // GANZEN Text (Kommentare eingeschlossen) belegt nur „die Phrase kommt // zweimal vor", nicht „beide set-Blöcke tragen die Ablaufzeit" — er bliebe // grün, wenn clupilot_blocked6 die Zeitoption verlöre, während dieselbe // Phrase zufällig zweimal in Prosa stünde. expect($regelwerk)->toMatch('/set clupilot_blocked \{\s*type ipv4_addr\s*flags timeout\s*\}/') ->and($regelwerk)->toMatch('/set clupilot_blocked6 \{\s*type ipv6_addr\s*flags timeout\s*\}/'); }); 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(); }); it('gibt false zurueck statt zu werfen, wenn der Host beim Sperren nicht erreichbar ist', function () { // Trägt Aufgabe 4: der Sperrdatensatz entsteht auch dann in der Datenbank // und wird beim nächsten Lauf erneut eingetragen. Eine Ausnahme hier würde // stattdessen den ganzen Zeitplan-Auftrag mitreißen, der block() aufruft. $shell = new FakeRemoteShell; $shell->failConnect = true; app()->instance(\App\Services\Ssh\RemoteShell::class, $shell); $host = Host::factory()->active()->create(['ssh_host_key' => 'SHA256:abc']); $result = app(HostFirewall::class)->block($host, '203.0.113.7', 3600); expect($result)->toBeFalse(); }); it('gibt false zurueck statt zu werfen, wenn der Host beim Entsperren nicht erreichbar ist', function () { $shell = new FakeRemoteShell; $shell->failConnect = true; app()->instance(\App\Services\Ssh\RemoteShell::class, $shell); $host = Host::factory()->active()->create(['ssh_host_key' => 'SHA256:abc']); $result = app(HostFirewall::class)->release($host, '203.0.113.7'); expect($result)->toBeFalse(); }); // ---- Eine Sperre, die nur in der Datenbank steht, darf nicht schweigen ---- it('meldet, wenn die Regel nicht auf den Host kam', function () { // Auf einem Host, der noch das alte Regelwerk ohne die Mengen traegt, // scheitert `nft add element` bei JEDEM Versuch — und beide Aufrufer // verwerfen den Rueckgabewert. Die Sperre stuende dann in Datenbank, // Portal, Konsole und in der Mail an den Kunden als aktiv und in der // Firewall nie. Exceptions::fake(); $shell = new FakeRemoteShell; $shell->defaultResult(\App\Services\Ssh\CommandResult::failure(1, 'Error: No such file or directory')); app()->instance(\App\Services\Ssh\RemoteShell::class, $shell); $host = Host::factory()->active()->create(['name' => 'pve-fsn-07', 'ssh_host_key' => 'SHA256:abc']); expect(app(HostFirewall::class)->block($host, '203.0.113.7', 3600))->toBeFalse(); // Konkrete Klasse, nicht Throwable: der Fake vergleicht den Typ des // Closure-Parameters EXAKT mit get_class() — mit Throwable trifft er nie // (siehe pest-toThrow-Falle, dasselbe Muster). Exceptions::assertReported(fn (RuntimeException $e) => str_contains($e->getMessage(), 'pve-fsn-07') && str_contains($e->getMessage(), '203.0.113.7')); }); it('meldet nichts, solange die Regel ankommt', function () { // Die Gegenprobe: eine Meldung, die immer kommt, ist keine. Exceptions::fake(); app()->instance(\App\Services\Ssh\RemoteShell::class, new FakeRemoteShell); $host = Host::factory()->active()->create(['ssh_host_key' => 'SHA256:abc']); app(HostFirewall::class)->block($host, '203.0.113.7', 3600); Exceptions::assertNothingReported(); }); // ---- Der Riegel: was hier hineingeht, wird als root auf dem Host ausgefuehrt ---- it('fuehrt bei einer Adresse mit Semikolon gar nichts aus und gibt false zurueck', function () { // `PhpseclibRemoteShell::run()` reicht die zusammengebaute Zeile an /bin/sh // als ROOT auf dem Proxmox-Host. Ein Semikolon in der Adresse waere dort // ein zweiter Befehl — der Dienst darf sich nicht darauf verlassen, dass // sein Aufrufer sauber war. $shell = new FakeRemoteShell; app()->instance(\App\Services\Ssh\RemoteShell::class, $shell); $host = Host::factory()->active()->create(['ssh_host_key' => 'SHA256:abc']); $result = app(HostFirewall::class)->block($host, '1.2.3.4; rm -rf /', 3600); expect($result)->toBeFalse() ->and($shell->recorded())->toBe([]) // Nicht einmal verbunden: was nicht ausgefuehrt werden darf, braucht // auch keine Wurzel-Sitzung. ->and($shell->connections)->toBe([]); }); it('weist auch beim Entsperren eine Adresse ab, die keine ist', function () { $shell = new FakeRemoteShell; app()->instance(\App\Services\Ssh\RemoteShell::class, $shell); $host = Host::factory()->active()->create(['ssh_host_key' => 'SHA256:abc']); expect(app(HostFirewall::class)->release($host, '$(id)'))->toBeFalse() ->and($shell->recorded())->toBe([]); }); // ---- Buendeln: eine Verbindung je Host, nicht eine je Sperre ---- it('traegt drei Adressen desselben Hosts in EINER Verbindung ein', function () { // Bei dreissig Sperren waren das dreissig Handshakes pro Minute, seriell, // auf demselben Arbeiter, der die bezahlte Kundenbereitstellung faehrt — // und dreissig Sperren gibt es genau waehrend eines Angriffs. $shell = new FakeRemoteShell; app()->instance(\App\Services\Ssh\RemoteShell::class, $shell); $host = Host::factory()->active()->create(['ssh_host_key' => 'SHA256:abc']); $ok = app(HostFirewall::class)->blockMany($host, [ '203.0.113.7' => 1200, '203.0.113.8' => 600, '198.51.100.9' => 3600, ]); expect($ok)->toBeTrue() ->and($shell->connections)->toHaveCount(1) ->and($shell->recorded())->toHaveCount(1); // Und jede Adresse behaelt ihre EIGENE Restlaufzeit — eine gemeinsame // Dauer fuers Buendel waere bequemer und wuerde genau die Zusicherung // verwaessern, fuer die es einen eigenen Test gibt. expect($shell->ran('203.0.113.7 timeout 1200s'))->toBeTrue() ->and($shell->ran('203.0.113.8 timeout 600s'))->toBeTrue() ->and($shell->ran('198.51.100.9 timeout 3600s'))->toBeTrue(); }); it('trennt v4 und v6 in zwei Befehle, aber nicht in zwei Verbindungen', 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)->blockMany($host, ['203.0.113.7' => 60, '2001:db8::1' => 60]); expect($shell->connections)->toHaveCount(1) ->and($shell->recorded())->toHaveCount(2) ->and($shell->ran('clupilot_blocked { 203.0.113.7 timeout 60s }'))->toBeTrue() ->and($shell->ran('clupilot_blocked6 { 2001:db8::1 timeout 60s }'))->toBeTrue(); }); it('fuehrt aus einem Buendel gar nichts aus, wenn eine einzige Adresse keine ist', function () { $shell = new FakeRemoteShell; app()->instance(\App\Services\Ssh\RemoteShell::class, $shell); $host = Host::factory()->active()->create(['ssh_host_key' => 'SHA256:abc']); $ok = app(HostFirewall::class)->blockMany($host, [ '203.0.113.7' => 60, '1.2.3.4; nft flush ruleset' => 60, ]); expect($ok)->toBeFalse() ->and($shell->recorded())->toBe([]); });