Gesperrte Adressen: Datensatz, Verdopplung und die Liste, die nie gesperrt wird

BlockAddress trifft die Sperr-Entscheidung: Ausnahmeliste (Verwaltungsnetz,
Loopback, eigene öffentliche Adresse — hart verdrahtet), laufende Sperre nicht
doppelt, Verdopplung binnen 24h bis zur 24h-Obergrenze. Der Datensatz entsteht
unabhängig vom Rückgabewert von HostFirewall::block() — eine Sperre nur in der
Datenbank ist sichtbar und wird nachgeholt (Aufgabe 4), eine Ausnahme dort
würde den Zeitplan-Auftrag mitreißen.

Migration bringt security_blocks und im selben Zug die zwei Cursor, die
Aufgabe 4 braucht: instances.security_log_offset, hosts.security_log_seen_at.
feat/versandtakt
nexxo 2026-08-03 13:16:11 +02:00
parent 8f630c5093
commit fc3ff3cb28
7 changed files with 428 additions and 1 deletions

View File

@ -24,7 +24,7 @@ class Host extends Model implements ProvisioningSubject
'name', 'datacenter', 'cluster', 'public_ip', 'wg_ip', 'wg_pubkey',
'ssh_host_key', 'api_token_ref', 'total_gb', 'total_ram_mb', 'cpu_cores',
'cpu_weight', 'reserve_pct', 'pve_version', 'node', 'status', 'last_seen_at', 'dns_record_id',
'reserved_for_customer_id',
'reserved_for_customer_id', 'security_log_seen_at',
];
protected $hidden = ['api_token_ref'];
@ -33,6 +33,7 @@ class Host extends Model implements ProvisioningSubject
{
return [
'last_seen_at' => 'datetime',
'security_log_seen_at' => 'datetime',
'total_gb' => 'integer',
'total_ram_mb' => 'integer',
'cpu_cores' => 'integer',

View File

@ -23,6 +23,7 @@ class Instance extends Model
'subdomain', 'custom_domain', 'nc_admin_ref', 'admin_password', 'credentials_acknowledged_at',
'route_written', 'routed_hostnames', 'routed_backend', 'cert_ok', 'status', 'suspended_at', 'cancel_requested_at', 'service_ends_at',
'domain_token', 'domain_verified_at', 'domain_cert_ok', 'domain_checked_at', 'domain_error', 'domain_failures',
'security_log_offset',
];
protected $hidden = ['nc_admin_ref', 'admin_password'];
@ -41,6 +42,7 @@ class Instance extends Model
'domain_verified_at' => 'datetime',
'domain_checked_at' => 'datetime',
'domain_failures' => 'integer',
'security_log_offset' => 'integer',
'domain_cert_ok' => 'boolean',
'route_written' => 'boolean',
'routed_hostnames' => 'array',

View File

@ -0,0 +1,82 @@
<?php
namespace App\Models;
use App\Models\Concerns\HasUuid;
use App\Services\Security\HostFirewall;
use Database\Factories\SecurityBlockFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
/**
* Eine Sperre gegen eine Adresse an genau einem Subjekt, einer Instanz oder
* einem Host (nie beiden). Entsteht in `BlockAddress` auch dann, wenn
* `HostFirewall::block()` selbst scheitert: der Datensatz IST die Sperre, die
* Firewall-Menge ist nur ihr aktueller Abdruck (siehe HostFirewall).
*/
class SecurityBlock extends Model
{
/** @use HasFactory<SecurityBlockFactory> */
use HasFactory, HasUuid;
protected $fillable = [
'host_id', 'instance_id', 'ip', 'reason', 'attempts', 'strikes',
'blocked_at', 'expires_at', 'released_at', 'released_by_type', 'released_by_id',
];
protected function casts(): array
{
return [
'attempts' => 'integer',
'strikes' => 'integer',
'blocked_at' => 'datetime',
'expires_at' => 'datetime',
'released_at' => 'datetime',
];
}
public function host(): BelongsTo
{
return $this->belongsTo(Host::class);
}
public function instance(): BelongsTo
{
return $this->belongsTo(Instance::class);
}
/** Wer vorzeitig aufgehoben hat — Operator oder Customer, wenn jemand. */
public function releasedBy(): MorphTo
{
return $this->morphTo();
}
/** Was gerade gilt: noch nicht aufgehoben und noch nicht abgelaufen. */
public function scopeActive(Builder $query): Builder
{
return $query->whereNull('released_at')->where('expires_at', '>', now());
}
/**
* Hebt die Sperre vorzeitig auf am Datensatz UND, wenn das Subjekt einen
* erreichbaren Host hat, in dessen Firewall. `$by` ist null, wenn der
* Kernel sie ohnehin schon fallen ließ (Ablauf) und niemand sie manuell
* aufgehoben hat; sonst der Operator oder Customer, der den Knopf gedrückt
* hat (R23-Modal auf der aufrufenden Seite, nicht hier).
*/
public function release(?Model $by): void
{
$this->releasedBy()->associate($by);
$this->released_at = now();
$this->save();
$host = $this->host ?? $this->instance?->host;
if ($host !== null) {
app(HostFirewall::class)->release($host, $this->ip);
}
}
}

View File

@ -0,0 +1,153 @@
<?php
namespace App\Services\Security;
use App\Models\Host;
use App\Models\Instance;
use App\Models\SecurityBlock;
use App\Support\ProvisioningSettings;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\IpUtils;
/**
* Die Sperr-Entscheidung: erst die Ausnahmeliste, dann ob schon eine Sperre
* läuft, dann die Verdopplung bei Wiederholung und erst danach der
* Datensatz. Wer die Fehlversuche zählt und diese Klasse aufruft, ist eine
* eigene, spätere Aufgabe.
*
* Ruft HostFirewall::block() auf, sobald ein erreichbarer Host feststeht.
* Deren Kopfkommentar ist bindend: block() wirft nicht, wenn der Host gerade
* nicht erreichbar ist, sondern gibt false zurück und der Datensatz hier
* entsteht UNABHÄNGIG von diesem Rückgabewert. Eine Sperre, die nur in der
* Datenbank steht, ist sichtbar und wird beim nächsten Lauf erneut
* eingetragen (eigene, spätere Aufgabe); eine Ausnahme aus block() würde
* stattdessen den ganzen Zeitplan-Auftrag mitreißen, der diese Klasse
* aufruft.
*/
class BlockAddress
{
/** Erste Sperre. Jede Wiederholung binnen 24h verdoppelt bis zur Obergrenze. */
private const BASE_SECONDS = 3600;
private const MAX_SECONDS = 86400;
public function __construct(private HostFirewall $firewall) {}
public function forInstance(Instance $instance, string $ip, int $attempts): ?SecurityBlock
{
if ($this->isExempt($ip) || $this->hasActiveBlock($ip, instanceId: $instance->id, hostId: null)) {
return null;
}
return $this->createBlock(
ip: $ip,
attempts: $attempts,
reason: 'instance_login',
instanceId: $instance->id,
hostId: null,
firewallHost: $instance->host,
);
}
public function forHost(Host $host, string $ip, int $attempts): ?SecurityBlock
{
if ($this->isExempt($ip) || $this->hasActiveBlock($ip, instanceId: null, hostId: $host->id)) {
return null;
}
return $this->createBlock(
ip: $ip,
attempts: $attempts,
reason: 'host_ssh',
instanceId: null,
hostId: $host->id,
firewallHost: $host,
);
}
/** Läuft für diese Adresse an diesem Subjekt schon eine Sperre? */
private function hasActiveBlock(string $ip, ?int $instanceId, ?int $hostId): bool
{
return SecurityBlock::query()
->where('ip', $ip)
->when($instanceId !== null, fn ($q) => $q->where('instance_id', $instanceId))
->when($hostId !== null, fn ($q) => $q->where('host_id', $hostId))
->active()
->exists();
}
private function createBlock(
string $ip,
int $attempts,
string $reason,
?int $instanceId,
?int $hostId,
?Host $firewallHost,
): SecurityBlock {
$strikes = $this->strikesWithinADay($ip, $instanceId, $hostId) + 1;
$seconds = min(self::BASE_SECONDS * 2 ** ($strikes - 1), self::MAX_SECONDS);
$blockedAt = now();
$block = SecurityBlock::create([
'instance_id' => $instanceId,
'host_id' => $hostId,
'ip' => $ip,
'reason' => $reason,
'attempts' => $attempts,
'strikes' => $strikes,
'blocked_at' => $blockedAt,
'expires_at' => $blockedAt->copy()->addSeconds($seconds),
]);
// Ohne Host (Instanz noch nicht platziert) gibt es nichts einzutragen —
// der Datensatz steht trotzdem, und eine spätere Zuweisung findet ihn.
if ($firewallHost !== null) {
$this->firewall->block($firewallHost, $ip, $seconds);
}
return $block;
}
/**
* Die wievielte Sperre dieser Adresse an diesem Subjekt in den letzten 24
* Stunden das hier wird. Zählt JEDE Sperre in dem Fenster, auch eine
* inzwischen vorzeitig aufgehobene wer freigibt, hebt die Sperre auf,
* nicht die Erinnerung daran, dass sie fällig war.
*/
private function strikesWithinADay(string $ip, ?int $instanceId, ?int $hostId): int
{
return SecurityBlock::query()
->where('ip', $ip)
->when($instanceId !== null, fn ($q) => $q->where('instance_id', $instanceId))
->when($hostId !== null, fn ($q) => $q->where('host_id', $hostId))
->where('blocked_at', '>=', now()->subDay())
->count();
}
private function isExempt(string $ip): bool
{
return $ip !== '' && IpUtils::checkIp($ip, $this->exemptRanges());
}
/**
* Die Ausnahmeliste. Hart verdrahtet, ohne Schalter: über das
* Verwaltungsnetz `10.66.0.0/24` erreicht CluPilot den Host überhaupt
* eine Sperre dort wäre das Ende der Fernwartung.
*
* @return array<int, string>
*/
private function exemptRanges(): array
{
$ranges = ['10.66.0.0/24', '127.0.0.1', '::1'];
$endpoint = ProvisioningSettings::wgEndpoint();
// Leer ist kein Fehler (frische Installation ohne Endpoint) — dann
// fällt genau dieser eine Eintrag der Liste weg.
if ($endpoint !== '') {
$ranges[] = Str::beforeLast($endpoint, ':');
}
return $ranges;
}
}

View File

@ -0,0 +1,44 @@
<?php
namespace Database\Factories;
use App\Models\Host;
use App\Models\Instance;
use App\Models\SecurityBlock;
use Illuminate\Database\Eloquent\Factories\Factory;
/** @extends Factory<SecurityBlock> */
class SecurityBlockFactory extends Factory
{
protected $model = SecurityBlock::class;
public function definition(): array
{
return [
'instance_id' => Instance::factory(),
'ip' => $this->faker->unique()->ipv4(),
'reason' => 'instance_login',
'attempts' => 10,
'strikes' => 1,
'blocked_at' => now(),
'expires_at' => now()->addHour(),
];
}
/** Eine Host- statt Instanz-Sperre, am übergebenen Host. */
public function forHost(Host $host): static
{
return $this->state(fn () => [
'instance_id' => null,
'host_id' => $host->id,
'reason' => 'host_ssh',
]);
}
public function released(): static
{
return $this->state(fn () => [
'released_at' => now(),
]);
}
}

View File

@ -0,0 +1,76 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Der Datensatz hinter jeder Sperre unabhängig davon, ob `HostFirewall`
* sie gerade eintragen konnte oder nicht (siehe deren Kopfkommentar). Genau
* eines von `host_id`/`instance_id` ist gesetzt: eine Instanz-Sperre hängt am
* Kunden und verschwindet mit ihm (`cascadeOnDelete`), eine Host-Sperre
* gehört zum Bestand und überlebt einen gelöschten Host als Protokollzeile
* (`nullOnDelete`).
*
* `expires_at` ist die Wahrheit für „gilt gerade" — nicht ein separates Flag,
* das mit dem Ablauf im Kernel auseinanderlaufen könnte (die Menge in
* nftables läuft mit `flags timeout` von selbst ab, siehe HostFirewall).
*
* `released_by_type`/`released_by_id` bleiben ausgeschrieben statt über
* `nullableMorphs()`, im Stil von `provisioning_runs.subject_type` dieselbe
* Idee, derselbe Rumpf.
*
* Im selben Zug die zwei Cursor, die der Scan-Auftrag (eigene, spätere
* Aufgabe) braucht, um bei jedem Lauf nur das Neue zu lesen: der
* Byte-Versatz im Nextcloud-Protokoll je Instanz, der Zeitpunkt der letzten
* gelesenen SSH-Zeile je Host.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('security_blocks', function (Blueprint $table) {
$table->id();
$table->uuid('uuid')->unique();
$table->foreignId('host_id')->nullable()->constrained('hosts')->nullOnDelete();
$table->foreignId('instance_id')->nullable()->constrained('instances')->cascadeOnDelete();
$table->string('ip', 45);
$table->string('reason', 32); // instance_login | host_ssh
$table->unsignedInteger('attempts');
$table->unsignedTinyInteger('strikes')->default(1);
$table->timestamp('blocked_at');
$table->timestamp('expires_at');
$table->timestamp('released_at')->nullable();
$table->string('released_by_type')->nullable();
$table->unsignedBigInteger('released_by_id')->nullable();
$table->timestamps();
$table->index(['instance_id', 'ip']);
$table->index(['host_id', 'ip']);
$table->index('expires_at');
});
Schema::table('instances', function (Blueprint $table) {
// Versatz im Nextcloud-Protokoll — ab hier liest der nächste Lauf.
$table->unsignedBigInteger('security_log_offset')->default(0);
});
Schema::table('hosts', function (Blueprint $table) {
// Zeitpunkt der zuletzt gelesenen SSH-Protokollzeile.
$table->timestamp('security_log_seen_at')->nullable();
});
}
public function down(): void
{
Schema::table('hosts', function (Blueprint $table) {
$table->dropColumn('security_log_seen_at');
});
Schema::table('instances', function (Blueprint $table) {
$table->dropColumn('security_log_offset');
});
Schema::dropIfExists('security_blocks');
}
};

View File

@ -0,0 +1,69 @@
<?php // tests/Feature/Security/BlockAddressTest.php
use App\Models\Host;
use App\Models\Instance;
use App\Models\SecurityBlock;
use App\Services\Security\BlockAddress;
use Illuminate\Support\Carbon;
it('sperrt beim ersten Mal fuer eine Stunde', function () {
$instance = Instance::factory()->create();
$block = app(BlockAddress::class)->forInstance($instance, '203.0.113.7', 12);
expect($block)->not->toBeNull()
// diffInMinutes ist SIGNED (Carbon 3) — wie App\Models\Incident und
// Host::healthState() schon halten es hier: früher.diffInX(später)
// für ein positives Ergebnis, nicht wortwörtlich wie im Auftragszettel.
->and(now()->diffInMinutes($block->expires_at))->toBeGreaterThan(55)
->and(now()->diffInMinutes($block->expires_at))->toBeLessThan(65)
->and($block->strikes)->toBe(1)
->and($block->attempts)->toBe(12);
});
it('verdoppelt bei Wiederholung und haelt bei 24 Stunden an', function () {
$instance = Instance::factory()->create();
$dienst = app(BlockAddress::class);
$dauern = [];
for ($i = 0; $i < 7; $i++) {
$block = $dienst->forInstance($instance, '203.0.113.7', 10);
$dauern[] = (int) round($block->blocked_at->diffInHours($block->expires_at));
$block->release(null); // freigegeben, aber der Zähler bleibt
}
expect($dauern)->toBe([1, 2, 4, 8, 16, 24, 24]);
});
it('faengt nach 24 Stunden ohne Vorfall wieder bei einer Stunde an', function () {
$instance = Instance::factory()->create();
$dienst = app(BlockAddress::class);
$dienst->forInstance($instance, '203.0.113.7', 10)->release(null);
Carbon::setTestNow(now()->addHours(25));
$zweiter = $dienst->forInstance($instance, '203.0.113.7', 10);
expect($zweiter->strikes)->toBe(1);
});
it('sperrt NIEMALS eine Adresse aus dem Verwaltungsnetz', function () {
// Eine Sperrliste, die sich selbst aussperren kann, ist eine Falle: über
// genau dieses Netz erreicht CluPilot den Host.
$host = Host::factory()->create();
expect(app(BlockAddress::class)->forHost($host, '10.66.0.1', 999))->toBeNull()
->and(app(BlockAddress::class)->forHost($host, '127.0.0.1', 999))->toBeNull()
->and(app(BlockAddress::class)->forHost($host, '::1', 999))->toBeNull()
->and(SecurityBlock::count())->toBe(0);
});
it('haelt eine laufende Sperre nicht zweimal', function () {
$instance = Instance::factory()->create();
$dienst = app(BlockAddress::class);
$dienst->forInstance($instance, '203.0.113.7', 10);
expect($dienst->forInstance($instance, '203.0.113.7', 10))->toBeNull()
->and(SecurityBlock::count())->toBe(1);
});