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
parent
8f630c5093
commit
fc3ff3cb28
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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);
|
||||
});
|
||||
Loading…
Reference in New Issue