Sperrliste in der Host-Firewall, unter der Regel fuer bestehende Verbindungen

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.
feat/versandtakt
nexxo 2026-08-03 12:55:34 +02:00
parent 5dcfee9957
commit 77a4c3d990
3 changed files with 154 additions and 0 deletions

View File

@ -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,

View File

@ -0,0 +1,77 @@
<?php
namespace App\Services\Security;
use App\Models\Host;
use App\Services\Secrets\SecretVault;
use App\Services\Ssh\RemoteShell;
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 (eigene, spätere Aufgabe)
* 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. Eine spätere Aufgabe legt den
* Sperrdatensatz in der Datenbank trotzdem an und 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.
*/
class HostFirewall
{
public function __construct(private RemoteShell $shell) {}
public function block(Host $host, string $ip, int $seconds): bool
{
return $this->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;
}
}
}

View File

@ -0,0 +1,56 @@
<?php // tests/Feature/Security/HostFirewallTest.php
use App\Models\Host;
use App\Provisioning\Steps\Host\SecureHostFirewall;
use App\Services\Security\HostFirewall;
use App\Services\Ssh\FakeRemoteShell;
it('stellt die Sperrregel UNTER die Regel fuer bestehende Verbindungen', function () {
// Das ist die eigentliche Zusage des ganzen Systems: „wer drin ist, bleibt
// drin". Sie hängt an dieser Reihenfolge und an nichts sonst. Geprüft am
// ERZEUGTEN Regelwerk, nicht am Quelltext — ein Test gegen den Quelltext
// wäre auch dann grün, wenn die Zeilen im Ergebnis anders herum stünden.
$shell = new FakeRemoteShell;
app()->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();
});