CluPilotCloud/app/Console/Commands/RefreshHostFirewall.php

150 lines
5.5 KiB
PHP

<?php
namespace App\Console\Commands;
use App\Models\Host;
use App\Models\ProvisioningRun;
use App\Provisioning\Jobs\AdvanceRunJob;
use Illuminate\Console\Command;
/**
* Schreibt einem bereits übernommenen Host sein nftables-Regelwerk neu.
*
* Der Anlass: die beiden Sperrmengen `clupilot_blocked`/`clupilot_blocked6`
* kamen mit dem Frühwarnsystem ins Regelwerk. Jeder Host, der VORHER übernommen
* wurde, trägt noch die alte Datei — dort scheitert `nft add element` bei jedem
* Versuch, und die Sperre steht in Datenbank, Portal, Konsole und in der Mail
* an den Kunden als aktiv, in der Firewall aber nie. `HostFirewall` meldet
* diesen Fall inzwischen (`report()`); DAS hier ist der Griff, mit dem man ihn
* abstellt.
*
* Warum ein Befehl und kein Zeitplan — dieselbe Begründung wie bei
* `clupilot:apply-quotas`, an dem sich dieser hier orientiert:
*
* - das Loch ist endlich und schließt sich endgültig. Jeder ab jetzt
* übernommene Host bekommt die Mengen im Onboarding;
* - ein nächtlicher Lauf wäre eine zweite Instanz, die dieselbe Datei auf
* laufende Maschinen schreibt, und würde am Tag, an dem der Schritt in der
* Pipeline still kaputtginge, für ihn einspringen — das Versagen käme nie
* ans Licht;
* - eine Reparatur, die der Betreiber anstößt, ist eine, deren Ausgabe er
* liest. Dieser Befehl sagt, was er anfasst und was nicht, und rührt unter
* `--dry-run` nichts an.
*
* Die Arbeit selbst läuft über die `host-firewall`-Pipeline, also über
* denselben `SecureHostFirewall`-Schritt, den auch das Onboarding fährt —
* gedrosselt, wiederholt, protokolliert und in der Konsole sichtbar wie jede
* andere Fernarbeit. Ein zweiter Weg, dieselbe Datei zu schreiben, würde
* driften.
*
* Der Schritt prüft von der Hostseite aus selbst nach, dass der Tunnel steht,
* und fasst nichts an, wenn nicht. Ein Host, der gerade nicht erreichbar ist,
* wird also nicht halb umgestellt.
*/
class RefreshHostFirewall extends Command
{
protected $signature = 'clupilot:refresh-host-firewall
{--dry-run : auflisten, was liefe, und nichts anfassen}
{--host= : nur dieser eine Host, per Name oder oeffentlicher Adresse}';
protected $description = 'Schreibt uebernommenen Hosts ihr nftables-Regelwerk neu — inklusive der Sperrmengen';
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
$started = 0;
/** @var array<string, int> Grund => Anzahl */
$skipped = [];
$query = Host::query();
if ($name = $this->option('host')) {
$query->where(fn ($q) => $q->where('name', $name)->orWhere('public_ip', $name));
}
foreach ($query->cursor() as $host) {
$reason = $this->reasonToSkip($host);
if ($reason !== null) {
$skipped[$reason] = ($skipped[$reason] ?? 0) + 1;
continue;
}
$started++;
$this->line(($dryRun ? '[Probelauf] ' : '')."{$host->name} ({$host->public_ip}) über {$host->wg_ip}");
if (! $dryRun) {
$this->startRun($host);
}
}
foreach ($skipped as $reason => $count) {
$this->line("übersprungen ({$reason}): {$count}");
}
$this->info($dryRun
? "Probelauf: {$started} Host(s) bekämen ihr Regelwerk neu, ".array_sum($skipped).' übersprungen. Nichts wurde geändert.'
: "{$started} Lauf/Läufe gestartet, ".array_sum($skipped).' übersprungen.');
return self::SUCCESS;
}
/**
* Warum dieser Host in Ruhe gelassen wird, oder null.
*
* Der Grund IST der Bericht: „12 übersprungen" und sonst nichts ist keine
* Auskunft, mit der jemand etwas anfangen kann — „12 sind noch in der
* Übernahme" und „12 haben keinen Tunnel" sehen von hier aus gleich aus.
*/
private function reasonToSkip(Host $host): ?string
{
// Der Schritt verbindet sich über die WireGuard-Adresse und prüft den
// Tunnel von der Hostseite. Ohne beides gibt es nichts nachzuziehen.
if (blank($host->wg_ip) || blank($host->ssh_host_key)) {
return 'kein Tunnel, kein gepinnter Schlüssel';
}
// Ein Host mitten in der Übernahme bekommt sein Regelwerk ohnehin
// gleich — und ein zweiter Lauf daneben schriebe dieselbe Datei.
if (! in_array($host->status, ['active', 'disabled'], true)) {
return 'nicht übernommen (Status '.$host->status.')';
}
if ($this->hasRunInFlight($host)) {
return 'anderer Lauf aktiv';
}
return null;
}
private function startRun(Host $host): void
{
$run = ProvisioningRun::create([
'subject_type' => Host::class,
'subject_id' => $host->id,
'pipeline' => 'host-firewall',
'status' => ProvisioningRun::STATUS_PENDING,
'current_step' => 0,
'context' => [],
]);
AdvanceRunJob::dispatch($run->uuid);
}
/**
* JEDER Lauf gegen diesen Host, absichtlich weit gefasst: das hier ist ein
* Reparatur-Durchgang. Er läuft wieder, und ein heute übergangener Host ist
* beim nächsten Aufruf dran. Nichts geht durch Warten verloren.
*/
private function hasRunInFlight(Host $host): bool
{
return ProvisioningRun::query()
->where('subject_type', Host::class)
->where('subject_id', $host->id)
->inFlight()
->exists();
}
}