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
parent
5dcfee9957
commit
77a4c3d990
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
Loading…
Reference in New Issue