154 lines
5.2 KiB
PHP
154 lines
5.2 KiB
PHP
<?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;
|
|
}
|
|
}
|