diff --git a/app/Models/Host.php b/app/Models/Host.php index f821cb9..70ccb58 100644 --- a/app/Models/Host.php +++ b/app/Models/Host.php @@ -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', diff --git a/app/Models/Instance.php b/app/Models/Instance.php index a567e84..5254b26 100644 --- a/app/Models/Instance.php +++ b/app/Models/Instance.php @@ -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', diff --git a/app/Models/SecurityBlock.php b/app/Models/SecurityBlock.php new file mode 100644 index 0000000..a988a20 --- /dev/null +++ b/app/Models/SecurityBlock.php @@ -0,0 +1,82 @@ + */ + 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); + } + } +} diff --git a/app/Services/Security/BlockAddress.php b/app/Services/Security/BlockAddress.php new file mode 100644 index 0000000..3cacf8c --- /dev/null +++ b/app/Services/Security/BlockAddress.php @@ -0,0 +1,153 @@ +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 + */ + 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; + } +} diff --git a/database/factories/SecurityBlockFactory.php b/database/factories/SecurityBlockFactory.php new file mode 100644 index 0000000..7a5042b --- /dev/null +++ b/database/factories/SecurityBlockFactory.php @@ -0,0 +1,44 @@ + */ +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(), + ]); + } +} diff --git a/database/migrations/2026_08_03_120000_clupilot_merkt_sich_gesperrte_adressen.php b/database/migrations/2026_08_03_120000_clupilot_merkt_sich_gesperrte_adressen.php new file mode 100644 index 0000000..183a5f5 --- /dev/null +++ b/database/migrations/2026_08_03_120000_clupilot_merkt_sich_gesperrte_adressen.php @@ -0,0 +1,76 @@ +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'); + } +}; diff --git a/tests/Feature/Security/BlockAddressTest.php b/tests/Feature/Security/BlockAddressTest.php new file mode 100644 index 0000000..2a0454d --- /dev/null +++ b/tests/Feature/Security/BlockAddressTest.php @@ -0,0 +1,69 @@ +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); +});